mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1bb7b2828 | |||
| bd292585a4 | |||
| 65b370de08 | |||
| 88e5830af0 | |||
| f495422fa9 | |||
| 080d3b93c6 | |||
| 604697f7f8 | |||
| b9de3ad370 |
+1
-4
@@ -10,9 +10,6 @@
|
|||||||
adamdotdevin
|
adamdotdevin
|
||||||
-agusbasari29 AI PR slop
|
-agusbasari29 AI PR slop
|
||||||
ariane-emory
|
ariane-emory
|
||||||
-atharvau AI review spamming literally every PR
|
|
||||||
-danieljoshuanazareth
|
|
||||||
-danieljoshuanazareth
|
|
||||||
edemaine
|
edemaine
|
||||||
-florianleibert
|
-florianleibert
|
||||||
fwang
|
fwang
|
||||||
@@ -20,8 +17,8 @@ iamdavidhill
|
|||||||
jayair
|
jayair
|
||||||
kitlangton
|
kitlangton
|
||||||
kommander
|
kommander
|
||||||
-opencode2026
|
|
||||||
r44vc0rp
|
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
|
||||||
|
-OpenCode2026
|
||||||
|
|||||||
@@ -33,6 +33,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
issue-id: ${{ github.event.issue.number }}
|
issue-id: ${{ github.event.issue.number }}
|
||||||
comment-id: ${{ github.event.comment.id }}
|
comment-id: ${{ github.event.comment.id }}
|
||||||
roles: admin,maintain,write
|
roles: admin,maintain
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
go through each PR merged since the last tag
|
|
||||||
|
|
||||||
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md
|
|
||||||
|
|
||||||
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -41,11 +41,9 @@
|
|||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@solid-primitives/timer": "1.4.4",
|
|
||||||
"@solid-primitives/websocket": "1.3.1",
|
"@solid-primitives/websocket": "1.3.1",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -79,7 +77,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -113,7 +111,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -130,7 +128,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "catalog:",
|
"@cloudflare/workers-types": "catalog:",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "1.3.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"drizzle-kit": "catalog:",
|
"drizzle-kit": "catalog:",
|
||||||
@@ -140,7 +138,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "2.0.0",
|
"@ai-sdk/anthropic": "2.0.0",
|
||||||
"@ai-sdk/openai": "2.0.2",
|
"@ai-sdk/openai": "2.0.2",
|
||||||
@@ -164,7 +162,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -188,7 +186,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -221,7 +219,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop-electron": {
|
"packages/desktop-electron": {
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -252,7 +250,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -281,7 +279,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -297,7 +295,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -338,8 +336,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||||
"@opentui/core": "0.1.90",
|
"@opentui/core": "0.1.87",
|
||||||
"@opentui/solid": "0.1.90",
|
"@opentui/solid": "0.1.87",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -421,7 +419,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -445,7 +443,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -456,7 +454,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -491,7 +489,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -537,7 +535,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -548,7 +546,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -612,7 +610,7 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "1.3.11",
|
"@types/bun": "1.3.9",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
"@types/node": "22.13.9",
|
"@types/node": "22.13.9",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
@@ -1448,21 +1446,21 @@
|
|||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.1.90", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="],
|
"@opentui/core": ["@opentui/core@0.1.87", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.1.90", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.90", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-zEHDpJOTGS707ts5j4diqoWuFLSqV6yARKl1H0FJkwWOotu+rxCyksL+C0gX0jJUonAw2cjlZ2NNtZY8g78zkg=="],
|
"@opentui/solid": ["@opentui/solid@0.1.87", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.87", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-lRT9t30l8+FtgOjjWJcdb2MT6hP8/RKqwGgYwTI7fXrOqdhxxwdP2SM+rH2l3suHeASheiTdlvPAo230iUcsvg=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
@@ -1890,8 +1888,6 @@
|
|||||||
|
|
||||||
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
|
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
|
||||||
|
|
||||||
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
|
|
||||||
|
|
||||||
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
|
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
|
||||||
|
|
||||||
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
|
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
|
||||||
@@ -1970,14 +1966,10 @@
|
|||||||
|
|
||||||
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="],
|
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="],
|
||||||
|
|
||||||
"@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="],
|
|
||||||
|
|
||||||
"@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="],
|
"@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="],
|
||||||
|
|
||||||
"@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="],
|
"@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="],
|
||||||
|
|
||||||
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
|
||||||
|
|
||||||
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||||
|
|
||||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
|
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
|
||||||
@@ -2060,7 +2052,7 @@
|
|||||||
|
|
||||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
|
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
|
||||||
|
|
||||||
@@ -2456,7 +2448,7 @@
|
|||||||
|
|
||||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
|
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
|
||||||
|
|
||||||
@@ -5206,6 +5198,8 @@
|
|||||||
|
|
||||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||||
|
|
||||||
|
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
|
||||||
|
|
||||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||||
|
|
||||||
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-E5neEbBiwQDhIQ5QVhijpHCCP9hcxm319S9WrDKngSw=",
|
"x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=",
|
||||||
"aarch64-linux": "sha256-lnwaGSEirl9izskDooB/xQ0ZdirW0t3/S+OoOnfYaoQ=",
|
"aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=",
|
||||||
"aarch64-darwin": "sha256-RDxxW9NMlGMIdIxTsbOYVqxunflkILv2dA7JqjnJgm4=",
|
"aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=",
|
||||||
"x86_64-darwin": "sha256-1tvvktu2NRg6N6ASuKzqzcEmMrzH3/LFey0Vxr4E8zg="
|
"x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.11",
|
"packageManager": "bun@1.3.10",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/platform-node": "4.0.0-beta.35",
|
"@effect/platform-node": "4.0.0-beta.35",
|
||||||
"@types/bun": "1.3.11",
|
"@types/bun": "1.3.9",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
|
|||||||
@@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
|
|||||||
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPalette(page: Page, key = "K") {
|
export async function openPalette(page: Page) {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await page.keyboard.press(`${modKey}+${key}`)
|
await page.keyboard.press(`${modKey}+P`)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { closeDialog, openPalette } from "../actions"
|
import { openPalette } from "../actions"
|
||||||
|
|
||||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -9,12 +9,3 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
|
|||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openPalette(page, "P")
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -108,10 +108,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
|||||||
await page.keyboard.type(draft)
|
await page.keyboard.type(draft)
|
||||||
await wait(page, draft)
|
await wait(page, draft)
|
||||||
|
|
||||||
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
await edge(page, "start")
|
||||||
await prompt.fill("")
|
|
||||||
await wait(page, "")
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await page.keyboard.press("ArrowUp")
|
||||||
await wait(page, second)
|
await wait(page, second)
|
||||||
|
|
||||||
@@ -122,7 +119,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
|||||||
await wait(page, second)
|
await wait(page, second)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, "")
|
await wait(page, draft)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
|
|||||||
await expect(keybindButton).toBeVisible()
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
const initialKeybind = await keybindButton.textContent()
|
||||||
expect(initialKeybind).toContain("K")
|
expect(initialKeybind).toContain("P")
|
||||||
|
|
||||||
await keybindButton.click()
|
await keybindButton.click()
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||||
defocus,
|
|
||||||
cleanupSession,
|
|
||||||
cleanupTestProject,
|
|
||||||
closeSidebar,
|
|
||||||
createTestProject,
|
|
||||||
hoverSessionItem,
|
|
||||||
openSidebar,
|
|
||||||
waitSession,
|
|
||||||
} from "../actions"
|
|
||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
|
||||||
|
|
||||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
@@ -47,72 +37,3 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
await cleanupSession({ sdk, sessionID: two.id })
|
await cleanupSession({ sdk, sessionID: two.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const other = await createTestProject()
|
|
||||||
const slug = dirSlug(other)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(
|
|
||||||
async () => {
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
|
||||||
const card = page.locator('[data-component="hover-card-content"]')
|
|
||||||
|
|
||||||
await expect(project).toBeVisible()
|
|
||||||
await project.hover()
|
|
||||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
|
||||||
|
|
||||||
await page.mouse.down()
|
|
||||||
await expect(card).toHaveCount(0)
|
|
||||||
await page.mouse.up()
|
|
||||||
|
|
||||||
await waitSession(page, { directory: other })
|
|
||||||
await expect(card).toHaveCount(0)
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(other)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const other = await createTestProject()
|
|
||||||
const slug = dirSlug(other)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(
|
|
||||||
async () => {
|
|
||||||
await openSidebar(page)
|
|
||||||
await defocus(page)
|
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
|
||||||
|
|
||||||
await expect(project).toBeVisible()
|
|
||||||
|
|
||||||
let hit = false
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
hit = await project.evaluate((el) => {
|
|
||||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
|
||||||
})
|
|
||||||
if (hit) break
|
|
||||||
await page.keyboard.press("Tab")
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(hit).toBe(true)
|
|
||||||
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await waitSession(page, { directory: other })
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(other)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { runTerminal, waitTerminalReady } from "../actions"
|
import { runTerminal, waitTerminalReady } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
|
import { terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
@@ -130,39 +130,3 @@ test("closing the active terminal tab falls back to the previous tab", async ({
|
|||||||
.toEqual({ count: 1, first: true })
|
.toEqual({ count: 1, first: true })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
|
||||||
const rename = `E2E term ${Date.now()}`
|
|
||||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
await open(page)
|
|
||||||
|
|
||||||
await expect(tab).toContainText(/Terminal 1/)
|
|
||||||
await tab.click({ button: "right" })
|
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
|
||||||
await expect(menu).toHaveCount(0)
|
|
||||||
|
|
||||||
const input = page.locator('#terminal-panel input[type="text"]').first()
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
await input.fill(rename)
|
|
||||||
await input.press("Enter")
|
|
||||||
|
|
||||||
await expect(input).toHaveCount(0)
|
|
||||||
await expect(tab).toContainText(rename)
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const state = await store(page, key)
|
|
||||||
return state?.all[0]?.title
|
|
||||||
},
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
)
|
|
||||||
.toBe(rename)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -51,11 +51,9 @@
|
|||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@solid-primitives/timer": "1.4.4",
|
|
||||||
"@solid-primitives/websocket": "1.3.1",
|
"@solid-primitives/websocket": "1.3.1",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Splash } from "@opencode-ai/ui/logo"
|
|||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
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 { type Duration, Effect } from "effect"
|
import { type Duration, Effect } from "effect"
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
@@ -82,11 +81,6 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
|||||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
function QueryProvider(props: ParentProps) {
|
|
||||||
const client = new QueryClient()
|
|
||||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppShellProviders(props: ParentProps) {
|
function AppShellProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
@@ -142,13 +136,11 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<UiI18nBridge>
|
<UiI18nBridge>
|
||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
<QueryProvider>
|
<DialogProvider>
|
||||||
<DialogProvider>
|
<MarkedProviderWithNativeParser>
|
||||||
<MarkedProviderWithNativeParser>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
</MarkedProviderWithNativeParser>
|
||||||
</MarkedProviderWithNativeParser>
|
</DialogProvider>
|
||||||
</DialogProvider>
|
|
||||||
</QueryProvider>
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</UiI18nBridge>
|
</UiI18nBridge>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createMemo, 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 { useLanguage } from "@/context/language"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { DialogSelectModel } from "./dialog-select-model"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string }) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type FormState = {
|
|||||||
apiKey: string
|
apiKey: string
|
||||||
models: ModelRow[]
|
models: ModelRow[]
|
||||||
headers: HeaderRow[]
|
headers: HeaderRow[]
|
||||||
|
saving: boolean
|
||||||
err: {
|
err: {
|
||||||
providerID?: string
|
providerID?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe("validateCustomProvider", () => {
|
|||||||
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||||
{ row: "h1", key: "", value: "", err: {} },
|
{ row: "h1", key: "", value: "", err: {} },
|
||||||
],
|
],
|
||||||
|
saving: false,
|
||||||
err: {},
|
err: {},
|
||||||
},
|
},
|
||||||
t,
|
t,
|
||||||
@@ -59,6 +60,7 @@ describe("validateCustomProvider", () => {
|
|||||||
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||||
{ row: "h1", key: "authorization", value: "two", err: {} },
|
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||||
],
|
],
|
||||||
|
saving: false,
|
||||||
err: {},
|
err: {},
|
||||||
},
|
},
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
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 { batch, For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
@@ -32,6 +31,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
apiKey: "",
|
apiKey: "",
|
||||||
models: [modelRow()],
|
models: [modelRow()],
|
||||||
headers: [headerRow()],
|
headers: [headerRow()],
|
||||||
|
saving: false,
|
||||||
err: {},
|
err: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,49 +116,48 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
return output.result
|
return output.result
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const save = async (e: SubmitEvent) => {
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
e.preventDefault()
|
||||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
if (form.saving) return
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
|
||||||
|
|
||||||
if (result.key) {
|
const result = validate()
|
||||||
await globalSDK.client.auth.set({
|
if (!result) return
|
||||||
|
|
||||||
|
setForm("saving", true)
|
||||||
|
|
||||||
|
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||||
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
|
const auth = result.key
|
||||||
|
? globalSDK.client.auth.set({
|
||||||
providerID: result.providerID,
|
providerID: result.providerID,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: result.key,
|
key: result.key,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
: Promise.resolve()
|
||||||
|
|
||||||
await globalSync.updateConfig({
|
auth
|
||||||
provider: { [result.providerID]: result.config },
|
.then(() =>
|
||||||
disabled_providers: nextDisabled,
|
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
dialog.close()
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||||
|
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
return result
|
.catch((err: unknown) => {
|
||||||
},
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
onSuccess: (result) => {
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
dialog.close()
|
})
|
||||||
showToast({
|
.finally(() => {
|
||||||
variant: "success",
|
setForm("saving", false)
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
|
||||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
|
||||||
})
|
})
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const save = (e: SubmitEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (saveMutation.isPending) return
|
|
||||||
|
|
||||||
const result = validate()
|
|
||||||
if (!result) return
|
|
||||||
saveMutation.mutate(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -313,14 +312,8 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
||||||
class="w-auto self-start"
|
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
||||||
type="submit"
|
|
||||||
size="large"
|
|
||||||
variant="primary"
|
|
||||||
disabled={saveMutation.isPending}
|
|
||||||
>
|
|
||||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { createMemo, For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
@@ -29,6 +28,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
color: props.project.icon?.color || "pink",
|
color: props.project.icon?.color || "pink",
|
||||||
iconUrl: props.project.icon?.override || "",
|
iconUrl: props.project.icon?.override || "",
|
||||||
startup: props.project.commands?.start ?? "",
|
startup: props.project.commands?.start ?? "",
|
||||||
|
saving: false,
|
||||||
dragOver: false,
|
dragOver: false,
|
||||||
iconHover: false,
|
iconHover: false,
|
||||||
})
|
})
|
||||||
@@ -71,37 +71,38 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
setStore("iconUrl", "")
|
setStore("iconUrl", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
mutationFn: async () => {
|
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
|
||||||
const start = store.startup.trim()
|
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
|
||||||
await globalSDK.client.project.update({
|
|
||||||
projectID: props.project.id,
|
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
|
||||||
icon: { color: store.color, override: store.iconUrl },
|
|
||||||
commands: { start },
|
|
||||||
})
|
|
||||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globalSync.project.meta(props.project.worktree, {
|
|
||||||
name,
|
|
||||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
|
||||||
commands: { start: start || undefined },
|
|
||||||
})
|
|
||||||
dialog.close()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
function handleSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (saveMutation.isPending) return
|
|
||||||
saveMutation.mutate()
|
await Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
setStore("saving", true)
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
|
if (props.project.id && props.project.id !== "global") {
|
||||||
|
await globalSDK.client.project.update({
|
||||||
|
projectID: props.project.id,
|
||||||
|
directory: props.project.worktree,
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, override: store.iconUrl },
|
||||||
|
commands: { start },
|
||||||
|
})
|
||||||
|
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
|
})
|
||||||
|
dialog.close()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStore("saving", false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -245,8 +246,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
||||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
{store.saving ? language.t("common.saving") : language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMutation } from "@tanstack/solid-query"
|
import { Component, createMemo, createSignal, Show } from "solid-js"
|
||||||
import { Component, createMemo, Show } from "solid-js"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -18,6 +17,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const [loading, setLoading] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync.data.mcp ?? {})
|
Object.entries(sync.data.mcp ?? {})
|
||||||
@@ -25,8 +25,10 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = useMutation(() => ({
|
const toggle = async (name: string) => {
|
||||||
mutationFn: async (name: string) => {
|
if (loading()) return
|
||||||
|
setLoading(name)
|
||||||
|
try {
|
||||||
const status = sync.data.mcp[name]
|
const status = sync.data.mcp[name]
|
||||||
if (status?.status === "connected") {
|
if (status?.status === "connected") {
|
||||||
await sdk.client.mcp.disconnect({ name })
|
await sdk.client.mcp.disconnect({ name })
|
||||||
@@ -36,8 +38,10 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
|
|
||||||
const result = await sdk.client.mcp.status()
|
const result = await sdk.client.mcp.status()
|
||||||
if (result.data) sync.set("mcp", result.data)
|
if (result.data) sync.set("mcp", result.data)
|
||||||
},
|
} finally {
|
||||||
}))
|
setLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
const totalCount = createMemo(() => items().length)
|
const totalCount = createMemo(() => items().length)
|
||||||
@@ -55,8 +59,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
filterKeys={["name", "status"]}
|
filterKeys={["name", "status"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x || toggle.isPending) return
|
if (x) toggle(x.name)
|
||||||
toggle.mutate(x.name)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
@@ -80,7 +83,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<Show when={statusLabel()}>
|
<Show when={statusLabel()}>
|
||||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={toggle.isPending && toggle.variables === i.name}>
|
<Show when={loading() === i.name}>
|
||||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,14 +92,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
||||||
checked={enabled()}
|
|
||||||
disabled={toggle.isPending && toggle.variables === i.name}
|
|
||||||
onChange={() => {
|
|
||||||
if (toggle.isPending) return
|
|
||||||
toggle.mutate(i.name)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||||
@@ -187,6 +186,7 @@ export function DialogSelectServer() {
|
|||||||
name: "",
|
name: "",
|
||||||
username: DEFAULT_USERNAME,
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
|
adding: false,
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
@@ -198,6 +198,7 @@ export function DialogSelectServer() {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
|
busy: false,
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -208,6 +209,7 @@ export function DialogSelectServer() {
|
|||||||
name: "",
|
name: "",
|
||||||
username: DEFAULT_USERNAME,
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
|
adding: false,
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
@@ -222,78 +224,10 @@ export function DialogSelectServer() {
|
|||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
status: undefined,
|
status: undefined,
|
||||||
|
busy: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (value: string) => {
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetAdd()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
http: { url: normalized },
|
|
||||||
}
|
|
||||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
|
||||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
|
||||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
|
||||||
const result = await checkServerHealth(conn.http)
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAdd()
|
|
||||||
await select(conn, true)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const editMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
|
||||||
if (input.original.type !== "http") return
|
|
||||||
const normalized = normalizeServerUrl(input.value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = store.editServer.name.trim() || undefined
|
|
||||||
const username = store.editServer.username || undefined
|
|
||||||
const password = store.editServer.password || undefined
|
|
||||||
const existingName = input.original.displayName
|
|
||||||
if (
|
|
||||||
normalized === input.original.http.url &&
|
|
||||||
name === existingName &&
|
|
||||||
username === input.original.http.username &&
|
|
||||||
password === input.original.http.password
|
|
||||||
) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
displayName: name,
|
|
||||||
http: { url: normalized, username, password },
|
|
||||||
}
|
|
||||||
const result = await checkServerHealth(conn.http)
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (normalized === input.original.http.url) {
|
|
||||||
server.add(conn)
|
|
||||||
} else {
|
|
||||||
replaceServer(input.original, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
resetEdit()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||||
const active = server.key
|
const active = server.key
|
||||||
const newConn = server.add(next)
|
const newConn = server.add(next)
|
||||||
@@ -362,7 +296,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddChange = (value: string) => {
|
const handleAddChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (store.addServer.adding) return
|
||||||
setStore("addServer", { url: value, error: "" })
|
setStore("addServer", { url: value, error: "" })
|
||||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -370,12 +304,12 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddNameChange = (value: string) => {
|
const handleAddNameChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (store.addServer.adding) return
|
||||||
setStore("addServer", { name: value, error: "" })
|
setStore("addServer", { name: value, error: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddUsernameChange = (value: string) => {
|
const handleAddUsernameChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (store.addServer.adding) return
|
||||||
setStore("addServer", { username: value, error: "" })
|
setStore("addServer", { username: value, error: "" })
|
||||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -383,7 +317,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddPasswordChange = (value: string) => {
|
const handleAddPasswordChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (store.addServer.adding) return
|
||||||
setStore("addServer", { password: value, error: "" })
|
setStore("addServer", { password: value, error: "" })
|
||||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -391,7 +325,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditChange = (value: string) => {
|
const handleEditChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (store.editServer.busy) return
|
||||||
setStore("editServer", { value, error: "" })
|
setStore("editServer", { value, error: "" })
|
||||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
@@ -399,12 +333,12 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditNameChange = (value: string) => {
|
const handleEditNameChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (store.editServer.busy) return
|
||||||
setStore("editServer", { name: value, error: "" })
|
setStore("editServer", { name: value, error: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditUsernameChange = (value: string) => {
|
const handleEditUsernameChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (store.editServer.busy) return
|
||||||
setStore("editServer", { username: value, error: "" })
|
setStore("editServer", { username: value, error: "" })
|
||||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
@@ -412,13 +346,85 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditPasswordChange = (value: string) => {
|
const handleEditPasswordChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (store.editServer.busy) return
|
||||||
setStore("editServer", { password: value, error: "" })
|
setStore("editServer", { password: value, error: "" })
|
||||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAdd(value: string) {
|
||||||
|
if (store.addServer.adding) return
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetAdd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("addServer", { adding: true, error: "" })
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
http: { url: normalized },
|
||||||
|
}
|
||||||
|
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||||
|
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||||
|
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
setStore("addServer", { adding: false })
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAdd()
|
||||||
|
await select(conn, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||||
|
if (store.editServer.busy || original.type !== "http") return
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = store.editServer.name.trim() || undefined
|
||||||
|
const username = store.editServer.username || undefined
|
||||||
|
const password = store.editServer.password || undefined
|
||||||
|
const existingName = original.displayName
|
||||||
|
if (
|
||||||
|
normalized === original.http.url &&
|
||||||
|
name === existingName &&
|
||||||
|
username === original.http.username &&
|
||||||
|
password === original.http.password
|
||||||
|
) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("editServer", { busy: true, error: "" })
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
displayName: name,
|
||||||
|
http: { url: normalized, username, password },
|
||||||
|
}
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
setStore("editServer", { busy: false })
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === original.http.url) {
|
||||||
|
server.add(conn)
|
||||||
|
} else {
|
||||||
|
replaceServer(original, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetEdit()
|
||||||
|
}
|
||||||
|
|
||||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||||
if (store.editServer.id) return "edit"
|
if (store.editServer.id) return "edit"
|
||||||
if (store.addServer.showForm) return "add"
|
if (store.addServer.showForm) return "add"
|
||||||
@@ -458,26 +464,23 @@ export function DialogSelectServer() {
|
|||||||
password: conn.http.password ?? "",
|
password: conn.http.password ?? "",
|
||||||
error: "",
|
error: "",
|
||||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||||
|
busy: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitForm = () => {
|
const submitForm = () => {
|
||||||
if (mode() === "add") {
|
if (mode() === "add") {
|
||||||
if (addMutation.isPending) return
|
void handleAdd(store.addServer.url)
|
||||||
setStore("addServer", { error: "" })
|
|
||||||
addMutation.mutate(store.addServer.url)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const original = editing()
|
const original = editing()
|
||||||
if (!original) return
|
if (!original) return
|
||||||
if (editMutation.isPending) return
|
void handleEdit(original, store.editServer.value)
|
||||||
setStore("editServer", { error: "" })
|
|
||||||
editMutation.mutate({ original, value: store.editServer.value })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFormMode = createMemo(() => mode() !== "list")
|
const isFormMode = createMemo(() => mode() !== "list")
|
||||||
const isAddMode = createMemo(() => mode() === "add")
|
const isAddMode = createMemo(() => mode() === "add")
|
||||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
|
||||||
|
|
||||||
const formTitle = createMemo(() => {
|
const formTitle = createMemo(() => {
|
||||||
if (!isFormMode()) return language.t("dialog.server.title")
|
if (!isFormMode()) return language.t("dialog.server.title")
|
||||||
|
|||||||
@@ -1043,7 +1043,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
isDialogActive: () => !!dialog.active,
|
isDialogActive: () => !!dialog.active,
|
||||||
setDraggingType: (type) => setStore("draggingType", type),
|
setDraggingType: (type) => setStore("draggingType", type),
|
||||||
@@ -1388,7 +1388,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
class="hidden"
|
class="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const list = e.currentTarget.files
|
const list = e.currentTarget.files
|
||||||
if (list) void addAttachments(Array.from(list))
|
if (list) {
|
||||||
|
for (const file of Array.from(list)) {
|
||||||
|
void addAttachment(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
e.currentTarget.value = ""
|
e.currentTarget.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -71,18 +71,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
|
|
||||||
const addAttachment = (file: File) => add(file)
|
const addAttachment = (file: File) => add(file)
|
||||||
|
|
||||||
const addAttachments = async (files: File[], toast = true) => {
|
|
||||||
let found = false
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const ok = await add(file, false)
|
|
||||||
if (ok) found = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!found && files.length > 0 && toast) warn()
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeAttachment = (id: string) => {
|
const removeAttachment = (id: string) => {
|
||||||
const current = prompt.current()
|
const current = prompt.current()
|
||||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||||
@@ -96,14 +84,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
const files = Array.from(clipboardData.items).flatMap((item) => {
|
const items = Array.from(clipboardData.items)
|
||||||
if (item.kind !== "file") return []
|
const fileItems = items.filter((item) => item.kind === "file")
|
||||||
const file = item.getAsFile()
|
|
||||||
return file ? [file] : []
|
|
||||||
})
|
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (fileItems.length > 0) {
|
||||||
await addAttachments(files)
|
let found = false
|
||||||
|
for (const item of fileItems) {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) continue
|
||||||
|
const ok = await add(file, false)
|
||||||
|
if (ok) found = true
|
||||||
|
}
|
||||||
|
if (!found) warn()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +169,12 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
const dropped = event.dataTransfer?.files
|
const dropped = event.dataTransfer?.files
|
||||||
if (!dropped) return
|
if (!dropped) return
|
||||||
|
|
||||||
await addAttachments(Array.from(dropped))
|
let found = false
|
||||||
|
for (const file of Array.from(dropped)) {
|
||||||
|
const ok = await add(file, false)
|
||||||
|
if (ok) found = true
|
||||||
|
}
|
||||||
|
if (!found && dropped.length > 0) warn()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -194,7 +191,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
addAttachment,
|
addAttachment,
|
||||||
addAttachments,
|
|
||||||
removeAttachment,
|
removeAttachment,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,32 +49,6 @@ describe("buildRequestParts", () => {
|
|||||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps multiple uploaded attachments in order", () => {
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
|
||||||
context: [],
|
|
||||||
images: [
|
|
||||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
|
||||||
{
|
|
||||||
type: "image",
|
|
||||||
id: "img_2",
|
|
||||||
filename: "b.pdf",
|
|
||||||
mime: "application/pdf",
|
|
||||||
dataUrl: "data:application/pdf;base64,BBB",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
text: "check these",
|
|
||||||
messageID: "msg_multi",
|
|
||||||
sessionID: "ses_multi",
|
|
||||||
sessionDirectory: "/repo",
|
|
||||||
})
|
|
||||||
|
|
||||||
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
|
||||||
|
|
||||||
expect(files).toHaveLength(2)
|
|
||||||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("deduplicates context files when prompt already includes same path", () => {
|
test("deduplicates context files when prompt already includes same path", () => {
|
||||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
|
|||||||
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||||
const value = "a\nb\nc"
|
const value = "a\nb\nc"
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||||
@@ -135,14 +135,11 @@ describe("prompt-input history", () => {
|
|||||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
|
|
||||||
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
|
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
|
|||||||
const atStart = position === 0
|
const atStart = position === 0
|
||||||
const atEnd = position === text.length
|
const atEnd = position === text.length
|
||||||
if (inHistory) return atStart || atEnd
|
if (inHistory) return atStart || atEnd
|
||||||
if (direction === "up") return position === 0 && text.length === 0
|
if (direction === "up") return position === 0
|
||||||
return position === text.length
|
return position === text.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,14 +267,14 @@ export function SessionContextTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
class="@container h-full"
|
class="@container h-full pb-10"
|
||||||
viewportRef={(el) => {
|
viewportRef={(el) => {
|
||||||
scroll = el
|
scroll = el
|
||||||
restoreScroll()
|
restoreScroll()
|
||||||
}}
|
}}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
|
<div class="px-6 pt-4 flex flex-col gap-10">
|
||||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||||
<For each={stats}>
|
<For each={stats}>
|
||||||
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
})
|
})
|
||||||
let input: HTMLInputElement | undefined
|
let input: HTMLInputElement | undefined
|
||||||
let blurFrame: number | undefined
|
let blurFrame: number | undefined
|
||||||
let editRequested = false
|
|
||||||
|
|
||||||
const isDefaultTitle = () => {
|
const isDefaultTitle = () => {
|
||||||
const number = props.terminal.titleNumber
|
const number = props.terminal.titleNumber
|
||||||
@@ -169,14 +168,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
left: `${store.menuPosition.x}px`,
|
left: `${store.menuPosition.x}px`,
|
||||||
top: `${store.menuPosition.y}px`,
|
top: `${store.menuPosition.y}px`,
|
||||||
}}
|
}}
|
||||||
onCloseAutoFocus={(e) => {
|
|
||||||
if (!editRequested) return
|
|
||||||
e.preventDefault()
|
|
||||||
editRequested = false
|
|
||||||
requestAnimationFrame(() => edit())
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
|
<DropdownMenu.Item onSelect={edit}>
|
||||||
<Icon name="edit" class="w-4 h-4 mr-2" />
|
<Icon name="edit" class="w-4 h-4 mr-2" />
|
||||||
{language.t("common.rename")}
|
{language.t("common.rename")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { Popover } from "@opencode-ai/ui/popover"
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
@@ -131,30 +130,41 @@ const useDefaultServerKey = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const useMcpToggleMutation = () => {
|
const useMcpToggle = (input: {
|
||||||
const sync = useSync()
|
sync: ReturnType<typeof useSync>
|
||||||
const sdk = useSDK()
|
sdk: ReturnType<typeof useSDK>
|
||||||
const language = useLanguage()
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = createSignal<string | null>(null)
|
||||||
|
|
||||||
return useMutation(() => ({
|
const toggle = async (name: string) => {
|
||||||
mutationFn: async (name: string) => {
|
if (loading()) return
|
||||||
const status = sync.data.mcp[name]
|
setLoading(name)
|
||||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
|
||||||
const result = await sdk.client.mcp.status()
|
try {
|
||||||
if (result.data) sync.set("mcp", result.data)
|
const status = input.sync.data.mcp[name]
|
||||||
},
|
await (status?.status === "connected"
|
||||||
onError: (err) => {
|
? input.sdk.client.mcp.disconnect({ name })
|
||||||
|
: input.sdk.client.mcp.connect({ name }))
|
||||||
|
const result = await input.sdk.client.mcp.status()
|
||||||
|
if (result.data) input.sync.set("mcp", result.data)
|
||||||
|
} catch (err) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: language.t("common.requestFailed"),
|
title: input.language.t("common.requestFailed"),
|
||||||
description: err instanceof Error ? err.message : String(err),
|
description: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
},
|
} finally {
|
||||||
}))
|
setLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, toggle }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPopover() {
|
export function StatusPopover() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -171,7 +181,7 @@ export function StatusPopover() {
|
|||||||
})
|
})
|
||||||
const health = useServerHealth(servers)
|
const health = useServerHealth(servers)
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||||
const toggleMcp = useMcpToggleMutation()
|
const mcp = useMcpToggle({ sync, sdk, language })
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||||
@@ -327,11 +337,8 @@ export function StatusPopover() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
onClick={() => {
|
onClick={() => mcp.toggle(name)}
|
||||||
if (toggleMcp.isPending) return
|
disabled={mcp.loading() === name}
|
||||||
toggleMcp.mutate(name)
|
|
||||||
}}
|
|
||||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
@@ -347,11 +354,8 @@ export function StatusPopover() {
|
|||||||
<div onClick={(event) => event.stopPropagation()}>
|
<div onClick={(event) => event.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
disabled={mcp.loading() === name}
|
||||||
onChange={() => {
|
onChange={() => mcp.toggle(name)}
|
||||||
if (toggleMcp.isPending) return
|
|
||||||
toggleMcp.mutate(name)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -40,11 +40,4 @@ describe("command keybind helpers", () => {
|
|||||||
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
||||||
expect(formatKeybind("none")).toBe("")
|
expect(formatKeybind("none")).toBe("")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formatKeybind prefers the first combo", () => {
|
|
||||||
const display = formatKeybind("mod+k,mod+p")
|
|
||||||
|
|
||||||
expect(display.includes("K") || display.includes("k")).toBe(true)
|
|
||||||
expect(display.includes("P") || display.includes("p")).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ export const dict = {
|
|||||||
|
|
||||||
"command.sidebar.toggle": "Toggle sidebar",
|
"command.sidebar.toggle": "Toggle sidebar",
|
||||||
"command.project.open": "Open project",
|
"command.project.open": "Open project",
|
||||||
"command.project.previous": "Previous project",
|
|
||||||
"command.project.next": "Next project",
|
|
||||||
"command.provider.connect": "Connect provider",
|
"command.provider.connect": "Connect provider",
|
||||||
"command.server.switch": "Switch server",
|
"command.server.switch": "Switch server",
|
||||||
"command.settings.open": "Open settings",
|
"command.settings.open": "Open settings",
|
||||||
@@ -276,7 +274,7 @@ export const dict = {
|
|||||||
"prompt.context.includeActiveFile": "Include active file",
|
"prompt.context.includeActiveFile": "Include active file",
|
||||||
"prompt.context.removeActiveFile": "Remove active file from context",
|
"prompt.context.removeActiveFile": "Remove active file from context",
|
||||||
"prompt.context.removeFile": "Remove file from context",
|
"prompt.context.removeFile": "Remove file from context",
|
||||||
"prompt.action.attachFile": "Add files",
|
"prompt.action.attachFile": "Add file",
|
||||||
"prompt.attachment.remove": "Remove attachment",
|
"prompt.attachment.remove": "Remove attachment",
|
||||||
"prompt.action.send": "Send",
|
"prompt.action.send": "Send",
|
||||||
"prompt.action.stop": "Stop",
|
"prompt.action.stop": "Stop",
|
||||||
|
|||||||
@@ -211,22 +211,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const stop = () => setState("sizing", false)
|
const stop = () => setState("sizing", false)
|
||||||
const blur = () => reset()
|
|
||||||
const hide = () => {
|
|
||||||
if (document.visibilityState !== "hidden") return
|
|
||||||
reset()
|
|
||||||
}
|
|
||||||
window.addEventListener("pointerup", stop)
|
window.addEventListener("pointerup", stop)
|
||||||
window.addEventListener("pointercancel", stop)
|
window.addEventListener("pointercancel", stop)
|
||||||
window.addEventListener("blur", stop)
|
window.addEventListener("blur", stop)
|
||||||
window.addEventListener("blur", blur)
|
|
||||||
document.addEventListener("visibilitychange", hide)
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("pointerup", stop)
|
window.removeEventListener("pointerup", stop)
|
||||||
window.removeEventListener("pointercancel", stop)
|
window.removeEventListener("pointercancel", stop)
|
||||||
window.removeEventListener("blur", stop)
|
window.removeEventListener("blur", stop)
|
||||||
window.removeEventListener("blur", blur)
|
|
||||||
document.removeEventListener("visibilitychange", hide)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -246,12 +237,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
navLeave.current = undefined
|
navLeave.current = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
disarm()
|
|
||||||
setState("hoverSession", undefined)
|
|
||||||
setHoverProject(undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const arm = () => {
|
const arm = () => {
|
||||||
if (layout.sidebar.opened()) return
|
if (layout.sidebar.opened()) return
|
||||||
if (state.hoverProject === undefined) return
|
if (state.hoverProject === undefined) return
|
||||||
@@ -320,7 +305,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const clearSidebarHoverState = () => {
|
const clearSidebarHoverState = () => {
|
||||||
if (layout.sidebar.opened()) return
|
if (layout.sidebar.opened()) return
|
||||||
reset()
|
setState("hoverSession", undefined)
|
||||||
|
setHoverProject(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateWithSidebarReset = (href: string) => {
|
const navigateWithSidebarReset = (href: string) => {
|
||||||
@@ -950,26 +936,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateProjectByOffset(offset: number) {
|
|
||||||
const projects = layout.projects.list()
|
|
||||||
if (projects.length === 0) return
|
|
||||||
|
|
||||||
const current = currentProject()?.worktree
|
|
||||||
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
|
|
||||||
const active = current ?? fallback
|
|
||||||
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
|
|
||||||
|
|
||||||
const target =
|
|
||||||
index === -1
|
|
||||||
? offset > 0
|
|
||||||
? projects[0]
|
|
||||||
: projects[projects.length - 1]
|
|
||||||
: projects[(index + offset + projects.length) % projects.length]
|
|
||||||
if (!target) return
|
|
||||||
|
|
||||||
openProject(target.worktree)
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateSessionByUnseen(offset: number) {
|
function navigateSessionByUnseen(offset: number) {
|
||||||
const sessions = currentSessions()
|
const sessions = currentSessions()
|
||||||
if (sessions.length === 0) return
|
if (sessions.length === 0) return
|
||||||
@@ -1036,20 +1002,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
keybind: "mod+o",
|
keybind: "mod+o",
|
||||||
onSelect: () => chooseProject(),
|
onSelect: () => chooseProject(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "project.previous",
|
|
||||||
title: language.t("command.project.previous"),
|
|
||||||
category: language.t("command.category.project"),
|
|
||||||
keybind: "mod+alt+arrowup",
|
|
||||||
onSelect: () => navigateProjectByOffset(-1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "project.next",
|
|
||||||
title: language.t("command.project.next"),
|
|
||||||
category: language.t("command.category.project"),
|
|
||||||
keybind: "mod+alt+arrowdown",
|
|
||||||
onSelect: () => navigateProjectByOffset(1),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "provider.connect",
|
id: "provider.connect",
|
||||||
title: language.t("command.provider.connect"),
|
title: language.t("command.provider.connect"),
|
||||||
@@ -1989,10 +1941,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
||||||
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
||||||
onProjectFocus: (worktree) => aim.activate(worktree),
|
onProjectFocus: (worktree) => aim.activate(worktree),
|
||||||
onHoverOpenChanged: (worktree, hoverOpen) => {
|
|
||||||
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
|
|
||||||
setState("hoverProject", hoverOpen ? worktree : undefined)
|
|
||||||
},
|
|
||||||
navigateToProject,
|
navigateToProject,
|
||||||
openSidebar: () => layout.sidebar.open(),
|
openSidebar: () => layout.sidebar.open(),
|
||||||
closeProject,
|
closeProject,
|
||||||
@@ -2386,12 +2334,14 @@ export default function Layout(props: ParentProps) {
|
|||||||
size={layout.sidebar.width()}
|
size={layout.sidebar.width()}
|
||||||
min={244}
|
min={244}
|
||||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||||
|
collapseThreshold={244}
|
||||||
onResize={(w) => {
|
onResize={(w) => {
|
||||||
setState("sizing", true)
|
setState("sizing", true)
|
||||||
if (sizet !== undefined) clearTimeout(sizet)
|
if (sizet !== undefined) clearTimeout(sizet)
|
||||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||||
layout.sidebar.resize(w)
|
layout.sidebar.resize(w)
|
||||||
}}
|
}}
|
||||||
|
onCollapse={layout.sidebar.close}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -157,45 +157,34 @@ const SessionHoverPreview = (props: {
|
|||||||
messageLabel: (message: Message) => string | undefined
|
messageLabel: (message: Message) => string | undefined
|
||||||
onMessageSelect: (message: Message) => void
|
onMessageSelect: (message: Message) => void
|
||||||
trigger: JSX.Element
|
trigger: JSX.Element
|
||||||
}): JSX.Element => {
|
}): JSX.Element => (
|
||||||
let ref: HTMLDivElement | undefined
|
<HoverCard
|
||||||
|
openDelay={1000}
|
||||||
return (
|
closeDelay={props.sidebarHovering() ? 600 : 0}
|
||||||
<HoverCard
|
placement="right-start"
|
||||||
openDelay={1000}
|
gutter={16}
|
||||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
shift={-2}
|
||||||
placement="right-start"
|
trigger={props.trigger}
|
||||||
gutter={16}
|
open={props.hoverSession() === props.session.id}
|
||||||
shift={-2}
|
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
||||||
trigger={<div ref={ref}>{props.trigger}</div>}
|
>
|
||||||
open={props.hoverSession() === props.session.id}
|
<Show
|
||||||
onOpenChange={(open) => {
|
when={props.hoverReady()}
|
||||||
if (!open) {
|
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||||
props.setHoverSession(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!ref?.matches(":hover")) return
|
|
||||||
props.setHoverSession(props.session.id)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Show
|
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
||||||
when={props.hoverReady()}
|
<MessageNav
|
||||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
messages={props.hoverMessages() ?? []}
|
||||||
>
|
current={undefined}
|
||||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
getLabel={props.messageLabel}
|
||||||
<MessageNav
|
onMessageSelect={props.onMessageSelect}
|
||||||
messages={props.hoverMessages() ?? []}
|
size="normal"
|
||||||
current={undefined}
|
class="w-60"
|
||||||
getLabel={props.messageLabel}
|
/>
|
||||||
onMessageSelect={props.onMessageSelect}
|
</div>
|
||||||
size="normal"
|
</Show>
|
||||||
class="w-60"
|
</HoverCard>
|
||||||
/>
|
)
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</HoverCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export type ProjectSidebarContext = {
|
|||||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||||
onProjectMouseLeave: (worktree: string) => void
|
onProjectMouseLeave: (worktree: string) => void
|
||||||
onProjectFocus: (worktree: string) => void
|
onProjectFocus: (worktree: string) => void
|
||||||
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
|
|
||||||
navigateToProject: (directory: string) => void
|
navigateToProject: (directory: string) => void
|
||||||
openSidebar: () => void
|
openSidebar: () => void
|
||||||
closeProject: (directory: string) => void
|
closeProject: (directory: string) => void
|
||||||
@@ -110,14 +109,8 @@ const ProjectTile = (props: {
|
|||||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||||
}}
|
}}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
if (event.button === 0 && !event.ctrlKey) {
|
|
||||||
props.setOpen(false)
|
|
||||||
props.setSuppressHover(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!props.overlay()) return
|
if (!props.overlay()) return
|
||||||
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
||||||
props.setOpen(false)
|
|
||||||
props.setSuppressHover(true)
|
props.setSuppressHover(true)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}}
|
}}
|
||||||
@@ -137,11 +130,12 @@ const ProjectTile = (props: {
|
|||||||
props.onProjectFocus(props.project.worktree)
|
props.onProjectFocus(props.project.worktree)
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.setOpen(false)
|
|
||||||
if (props.selected()) {
|
if (props.selected()) {
|
||||||
|
props.setSuppressHover(true)
|
||||||
layout.sidebar.toggle()
|
layout.sidebar.toggle()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
props.setSuppressHover(false)
|
||||||
props.navigateToProject(props.project.worktree)
|
props.navigateToProject(props.project.worktree)
|
||||||
}}
|
}}
|
||||||
onBlur={() => props.setOpen(false)}
|
onBlur={() => props.setOpen(false)}
|
||||||
@@ -198,6 +192,7 @@ const ProjectPreviewPanel = (props: {
|
|||||||
projectChildren: Accessor<Map<string, string[]>>
|
projectChildren: Accessor<Map<string, string[]>>
|
||||||
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
||||||
workspaceChildren: (directory: string) => Map<string, string[]>
|
workspaceChildren: (directory: string) => Map<string, string[]>
|
||||||
|
setOpen: (value: boolean) => void
|
||||||
ctx: ProjectSidebarContext
|
ctx: ProjectSidebarContext
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}): JSX.Element => (
|
}): JSX.Element => (
|
||||||
@@ -264,7 +259,7 @@ const ProjectPreviewPanel = (props: {
|
|||||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.ctx.openSidebar()
|
props.ctx.openSidebar()
|
||||||
props.ctx.onHoverOpenChanged(props.project.worktree, false)
|
props.setOpen(false)
|
||||||
if (props.selected()) return
|
if (props.selected()) return
|
||||||
props.ctx.navigateToProject(props.project.worktree)
|
props.ctx.navigateToProject(props.project.worktree)
|
||||||
}}
|
}}
|
||||||
@@ -289,16 +284,28 @@ export const SortableProject = (props: {
|
|||||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
|
open: false,
|
||||||
menu: false,
|
menu: false,
|
||||||
suppressHover: false,
|
suppressHover: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
|
|
||||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||||
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
|
const active = createMemo(
|
||||||
|
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
|
||||||
|
)
|
||||||
|
|
||||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
createEffect(() => {
|
||||||
|
if (preview()) return
|
||||||
|
if (!state.open) return
|
||||||
|
setState("open", false)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!selected()) return
|
||||||
|
if (!state.open) return
|
||||||
|
setState("open", false)
|
||||||
|
})
|
||||||
|
|
||||||
const label = (directory: string) => {
|
const label = (directory: string) => {
|
||||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||||
@@ -339,7 +346,7 @@ export const SortableProject = (props: {
|
|||||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||||
closeProject={props.ctx.closeProject}
|
closeProject={props.ctx.closeProject}
|
||||||
setMenu={(value) => setState("menu", value)}
|
setMenu={(value) => setState("menu", value)}
|
||||||
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
|
setOpen={(value) => setState("open", value)}
|
||||||
setSuppressHover={(value) => setState("suppressHover", value)}
|
setSuppressHover={(value) => setState("suppressHover", value)}
|
||||||
language={language}
|
language={language}
|
||||||
/>
|
/>
|
||||||
@@ -350,7 +357,7 @@ export const SortableProject = (props: {
|
|||||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||||
<Show when={preview() && !selected()} fallback={tile()}>
|
<Show when={preview() && !selected()} fallback={tile()}>
|
||||||
<HoverCard
|
<HoverCard
|
||||||
open={!state.suppressHover && hoverOpen() && !state.menu}
|
open={!state.suppressHover && state.open && !state.menu}
|
||||||
openDelay={0}
|
openDelay={0}
|
||||||
closeDelay={0}
|
closeDelay={0}
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
@@ -359,7 +366,7 @@ export const SortableProject = (props: {
|
|||||||
onOpenChange={(value) => {
|
onOpenChange={(value) => {
|
||||||
if (state.menu) return
|
if (state.menu) return
|
||||||
if (value && state.suppressHover) return
|
if (value && state.suppressHover) return
|
||||||
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
setState("open", value)
|
||||||
if (value) props.ctx.setHoverSession(undefined)
|
if (value) props.ctx.setHoverSession(undefined)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -374,6 +381,7 @@ export const SortableProject = (props: {
|
|||||||
projectChildren={projectChildren}
|
projectChildren={projectChildren}
|
||||||
workspaceSessions={workspaceSessions}
|
workspaceSessions={workspaceSessions}
|
||||||
workspaceChildren={workspaceChildren}
|
workspaceChildren={workspaceChildren}
|
||||||
|
setOpen={(value) => setState("open", value)}
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
language={language}
|
language={language}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+121
-129
@@ -1,6 +1,5 @@
|
|||||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { 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 {
|
import {
|
||||||
batch,
|
batch,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
@@ -328,7 +327,10 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [ui, setUi] = createStore({
|
const [ui, setUi] = createStore({
|
||||||
|
git: false,
|
||||||
pendingMessage: undefined as string | undefined,
|
pendingMessage: undefined as string | undefined,
|
||||||
|
restoring: undefined as string | undefined,
|
||||||
|
reverting: false,
|
||||||
reviewSnap: false,
|
reviewSnap: false,
|
||||||
scrollGesture: 0,
|
scrollGesture: 0,
|
||||||
scroll: {
|
scroll: {
|
||||||
@@ -504,6 +506,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const [followup, setFollowup] = createStore({
|
const [followup, setFollowup] = createStore({
|
||||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||||
|
sending: {} as Record<string, string | undefined>,
|
||||||
failed: {} as Record<string, string | undefined>,
|
failed: {} as Record<string, string | undefined>,
|
||||||
paused: {} as Record<string, boolean | undefined>,
|
paused: {} as Record<string, boolean | undefined>,
|
||||||
edit: {} as Record<
|
edit: {} as Record<
|
||||||
@@ -641,24 +644,25 @@ export default function Page() {
|
|||||||
globalSync.set("project", [...list, next])
|
globalSync.set("project", [...list, next])
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitMutation = useMutation(() => ({
|
|
||||||
mutationFn: () => sdk.client.project.initGit(),
|
|
||||||
onSuccess: (x) => {
|
|
||||||
if (!x.data) return
|
|
||||||
upsert(x.data)
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: formatServerError(err, language.t),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
function initGit() {
|
function initGit() {
|
||||||
if (gitMutation.isPending) return
|
if (ui.git) return
|
||||||
gitMutation.mutate()
|
setUi("git", true)
|
||||||
|
void sdk.client.project
|
||||||
|
.initGit()
|
||||||
|
.then((x) => {
|
||||||
|
if (!x.data) return
|
||||||
|
upsert(x.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: formatServerError(err, language.t),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setUi("git", false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
@@ -957,8 +961,8 @@ export default function Page() {
|
|||||||
{language.t("session.review.noVcs.createGit.description")}
|
{language.t("session.review.noVcs.createGit.description")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||||
{gitMutation.isPending
|
{ui.git
|
||||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||||
: language.t("session.review.noVcs.createGit.action")}
|
: language.t("session.review.noVcs.createGit.action")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1375,40 +1379,10 @@ export default function Page() {
|
|||||||
return followup.edit[id]
|
return followup.edit[id]
|
||||||
})
|
})
|
||||||
|
|
||||||
const followupMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
|
||||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
|
||||||
if (!item) return
|
|
||||||
|
|
||||||
if (input.manual) setFollowup("paused", input.sessionID, undefined)
|
|
||||||
setFollowup("failed", input.sessionID, undefined)
|
|
||||||
|
|
||||||
const ok = await sendFollowupDraft({
|
|
||||||
client: sdk.client,
|
|
||||||
sync,
|
|
||||||
globalSync,
|
|
||||||
draft: item,
|
|
||||||
optimisticBusy: item.sessionDirectory === sdk.directory,
|
|
||||||
}).catch((err) => {
|
|
||||||
setFollowup("failed", input.sessionID, input.id)
|
|
||||||
fail(err)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (!ok) return
|
|
||||||
|
|
||||||
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
|
||||||
if (input.manual) resumeScroll()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const followupBusy = (sessionID: string) =>
|
|
||||||
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
|
|
||||||
|
|
||||||
const sendingFollowup = createMemo(() => {
|
const sendingFollowup = createMemo(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (!followupBusy(id)) return
|
return followup.sending[id]
|
||||||
return followupMutation.variables?.id
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const queueEnabled = createMemo(() => {
|
const queueEnabled = createMemo(() => {
|
||||||
@@ -1448,15 +1422,37 @@ export default function Page() {
|
|||||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||||
if (!item) return Promise.resolve()
|
if (!item) return Promise.resolve()
|
||||||
if (followupBusy(sessionID)) return Promise.resolve()
|
if (followup.sending[sessionID]) return Promise.resolve()
|
||||||
|
|
||||||
return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
|
if (opts?.manual) setFollowup("paused", sessionID, undefined)
|
||||||
|
setFollowup("sending", sessionID, id)
|
||||||
|
setFollowup("failed", sessionID, undefined)
|
||||||
|
|
||||||
|
return sendFollowupDraft({
|
||||||
|
client: sdk.client,
|
||||||
|
sync,
|
||||||
|
globalSync,
|
||||||
|
draft: item,
|
||||||
|
optimisticBusy: item.sessionDirectory === sdk.directory,
|
||||||
|
})
|
||||||
|
.then((ok) => {
|
||||||
|
if (ok === false) return
|
||||||
|
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
|
||||||
|
if (opts?.manual) resumeScroll()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setFollowup("failed", sessionID, id)
|
||||||
|
fail(err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const editFollowup = (id: string) => {
|
const editFollowup = (id: string) => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
if (followupBusy(sessionID)) return
|
if (followup.sending[sessionID]) return
|
||||||
|
|
||||||
const item = queuedFollowups().find((entry) => entry.id === id)
|
const item = queuedFollowups().find((entry) => entry.id === id)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
@@ -1479,74 +1475,6 @@ export default function Page() {
|
|||||||
const halt = (sessionID: string) =>
|
const halt = (sessionID: string) =>
|
||||||
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
|
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
|
||||||
|
|
||||||
const revertMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
|
||||||
const prev = prompt.current().slice()
|
|
||||||
const last = info()?.revert
|
|
||||||
const value = draft(input.messageID)
|
|
||||||
batch(() => {
|
|
||||||
roll(input.sessionID, { messageID: input.messageID })
|
|
||||||
prompt.set(value)
|
|
||||||
})
|
|
||||||
await halt(input.sessionID)
|
|
||||||
.then(() => sdk.client.session.revert(input))
|
|
||||||
.then((result) => {
|
|
||||||
if (result.data) merge(result.data)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
batch(() => {
|
|
||||||
roll(input.sessionID, last)
|
|
||||||
prompt.set(prev)
|
|
||||||
})
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const restoreMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
|
|
||||||
const next = userMessages().find((item) => item.id > id)
|
|
||||||
const prev = prompt.current().slice()
|
|
||||||
const last = info()?.revert
|
|
||||||
|
|
||||||
batch(() => {
|
|
||||||
roll(sessionID, next ? { messageID: next.id } : undefined)
|
|
||||||
if (next) {
|
|
||||||
prompt.set(draft(next.id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prompt.reset()
|
|
||||||
})
|
|
||||||
|
|
||||||
const task = !next
|
|
||||||
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
|
||||||
: halt(sessionID).then(() =>
|
|
||||||
sdk.client.session.revert({
|
|
||||||
sessionID,
|
|
||||||
messageID: next.id,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
await task
|
|
||||||
.then((result) => {
|
|
||||||
if (result.data) merge(result.data)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
batch(() => {
|
|
||||||
roll(sessionID, last)
|
|
||||||
prompt.set(prev)
|
|
||||||
})
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
|
||||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
|
||||||
|
|
||||||
const fork = (input: { sessionID: string; messageID: string }) => {
|
const fork = (input: { sessionID: string; messageID: string }) => {
|
||||||
const value = draft(input.messageID)
|
const value = draft(input.messageID)
|
||||||
const dir = base64Encode(sdk.directory)
|
const dir = base64Encode(sdk.directory)
|
||||||
@@ -1568,13 +1496,77 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||||
if (reverting()) return
|
if (ui.reverting || ui.restoring) return
|
||||||
return revertMutation.mutateAsync(input)
|
const prev = prompt.current().slice()
|
||||||
|
const last = info()?.revert
|
||||||
|
const value = draft(input.messageID)
|
||||||
|
batch(() => {
|
||||||
|
setUi("reverting", true)
|
||||||
|
roll(input.sessionID, { messageID: input.messageID })
|
||||||
|
prompt.set(value)
|
||||||
|
})
|
||||||
|
return halt(input.sessionID)
|
||||||
|
.then(() => sdk.client.session.revert(input))
|
||||||
|
.then((result) => {
|
||||||
|
if (result.data) merge(result.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
batch(() => {
|
||||||
|
roll(input.sessionID, last)
|
||||||
|
prompt.set(prev)
|
||||||
|
})
|
||||||
|
fail(err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setUi("reverting", false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const restore = (id: string) => {
|
const restore = (id: string) => {
|
||||||
if (!params.id || reverting()) return
|
const sessionID = params.id
|
||||||
return restoreMutation.mutateAsync(id)
|
if (!sessionID || ui.restoring || ui.reverting) return
|
||||||
|
|
||||||
|
const next = userMessages().find((item) => item.id > id)
|
||||||
|
const prev = prompt.current().slice()
|
||||||
|
const last = info()?.revert
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setUi("restoring", id)
|
||||||
|
setUi("reverting", true)
|
||||||
|
roll(sessionID, next ? { messageID: next.id } : undefined)
|
||||||
|
if (next) {
|
||||||
|
prompt.set(draft(next.id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prompt.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
const task = !next
|
||||||
|
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
||||||
|
: halt(sessionID).then(() =>
|
||||||
|
sdk.client.session.revert({
|
||||||
|
sessionID,
|
||||||
|
messageID: next.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return task
|
||||||
|
.then((result) => {
|
||||||
|
if (result.data) merge(result.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
batch(() => {
|
||||||
|
roll(sessionID, last)
|
||||||
|
prompt.set(prev)
|
||||||
|
})
|
||||||
|
fail(err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
batch(() => {
|
||||||
|
setUi("restoring", (value) => (value === id ? undefined : value))
|
||||||
|
setUi("reverting", false)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const rolled = createMemo(() => {
|
const rolled = createMemo(() => {
|
||||||
@@ -1593,7 +1585,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const item = queuedFollowups()[0]
|
const item = queuedFollowups()[0]
|
||||||
if (!item) return
|
if (!item) return
|
||||||
if (followupBusy(sessionID)) return
|
if (followup.sending[sessionID]) return
|
||||||
if (followup.failed[sessionID] === item.id) return
|
if (followup.failed[sessionID] === item.id) return
|
||||||
if (followup.paused[sessionID]) return
|
if (followup.paused[sessionID]) return
|
||||||
if (composer.blocked()) return
|
if (composer.blocked()) return
|
||||||
@@ -1788,8 +1780,8 @@ export default function Page() {
|
|||||||
rolled().length > 0
|
rolled().length > 0
|
||||||
? {
|
? {
|
||||||
items: rolled(),
|
items: rolled(),
|
||||||
restoring: restoring(),
|
restoring: ui.restoring,
|
||||||
disabled: reverting(),
|
disabled: ui.reverting,
|
||||||
onRestore: restore,
|
onRestore: restore,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -25,6 +24,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
custom: cached?.custom ?? ([] as string[]),
|
custom: cached?.custom ?? ([] as string[]),
|
||||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||||
editing: false,
|
editing: false,
|
||||||
|
sending: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
@@ -126,40 +126,36 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyMutation = useMutation(() => ({
|
|
||||||
mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
|
|
||||||
onMutate: () => {
|
|
||||||
props.onSubmit()
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
replied = true
|
|
||||||
cache.delete(props.request.id)
|
|
||||||
},
|
|
||||||
onError: fail,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const rejectMutation = useMutation(() => ({
|
|
||||||
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
|
|
||||||
onMutate: () => {
|
|
||||||
props.onSubmit()
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
replied = true
|
|
||||||
cache.delete(props.request.id)
|
|
||||||
},
|
|
||||||
onError: fail,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
|
|
||||||
|
|
||||||
const reply = async (answers: QuestionAnswer[]) => {
|
const reply = async (answers: QuestionAnswer[]) => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
await replyMutation.mutateAsync(answers)
|
|
||||||
|
props.onSubmit()
|
||||||
|
setStore("sending", true)
|
||||||
|
try {
|
||||||
|
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
||||||
|
replied = true
|
||||||
|
cache.delete(props.request.id)
|
||||||
|
} catch (err) {
|
||||||
|
fail(err)
|
||||||
|
} finally {
|
||||||
|
setStore("sending", false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reject = async () => {
|
const reject = async () => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
await rejectMutation.mutateAsync()
|
|
||||||
|
props.onSubmit()
|
||||||
|
setStore("sending", true)
|
||||||
|
try {
|
||||||
|
await sdk.client.question.reject({ requestID: props.request.id })
|
||||||
|
replied = true
|
||||||
|
cache.delete(props.request.id)
|
||||||
|
} catch (err) {
|
||||||
|
fail(err)
|
||||||
|
} finally {
|
||||||
|
setStore("sending", false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||||
@@ -179,7 +175,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customToggle = () => {
|
const customToggle = () => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
|
|
||||||
if (!multi()) {
|
if (!multi()) {
|
||||||
setStore("customOn", store.tab, true)
|
setStore("customOn", store.tab, true)
|
||||||
@@ -202,14 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customOpen = () => {
|
const customOpen = () => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
if (!on()) setStore("customOn", store.tab, true)
|
if (!on()) setStore("customOn", store.tab, true)
|
||||||
setStore("editing", true)
|
setStore("editing", true)
|
||||||
customUpdate(input(), true)
|
customUpdate(input(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectOption = (optIndex: number) => {
|
const selectOption = (optIndex: number) => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
|
|
||||||
if (optIndex === options().length) {
|
if (optIndex === options().length) {
|
||||||
customOpen()
|
customOpen()
|
||||||
@@ -231,7 +227,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
if (store.editing) commitCustom()
|
if (store.editing) commitCustom()
|
||||||
|
|
||||||
if (store.tab >= total() - 1) {
|
if (store.tab >= total() - 1) {
|
||||||
@@ -244,14 +240,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
if (store.tab <= 0) return
|
if (store.tab <= 0) return
|
||||||
setStore("tab", store.tab - 1)
|
setStore("tab", store.tab - 1)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const jump = (tab: number) => {
|
const jump = (tab: number) => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
setStore("tab", tab)
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
}
|
}
|
||||||
@@ -274,7 +270,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||||
}
|
}
|
||||||
disabled={sending()}
|
disabled={store.sending}
|
||||||
onClick={() => jump(i())}
|
onClick={() => jump(i())}
|
||||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||||
/>
|
/>
|
||||||
@@ -285,16 +281,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
||||||
{language.t("ui.common.dismiss")}
|
{language.t("ui.common.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
<div data-slot="question-footer-actions">
|
<div data-slot="question-footer-actions">
|
||||||
<Show when={store.tab > 0}>
|
<Show when={store.tab > 0}>
|
||||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
||||||
{language.t("ui.common.back")}
|
{language.t("ui.common.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
||||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,7 +311,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
data-picked={picked()}
|
data-picked={picked()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={picked()}
|
aria-checked={picked()}
|
||||||
disabled={sending()}
|
disabled={store.sending}
|
||||||
onClick={() => selectOption(i())}
|
onClick={() => selectOption(i())}
|
||||||
>
|
>
|
||||||
<span data-slot="question-option-check" aria-hidden="true">
|
<span data-slot="question-option-check" aria-hidden="true">
|
||||||
@@ -349,7 +345,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
data-picked={on()}
|
data-picked={on()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
disabled={sending()}
|
disabled={store.sending}
|
||||||
onClick={customOpen}
|
onClick={customOpen}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -381,7 +377,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (sending()) {
|
if (store.sending) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -423,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
placeholder={language.t("ui.question.custom.placeholder")}
|
placeholder={language.t("ui.question.custom.placeholder")}
|
||||||
value={input()}
|
value={input()}
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={sending()}
|
disabled={store.sending}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
|
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -30,7 +29,6 @@ import { useSDK } from "@/context/sdk"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { messageAgentColor } from "@/utils/agent"
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||||
import { makeTimer } from "@solid-primitives/timer"
|
|
||||||
|
|
||||||
type MessageComment = {
|
type MessageComment = {
|
||||||
path: string
|
path: string
|
||||||
@@ -251,21 +249,38 @@ export function MessageTimeline(props: {
|
|||||||
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
||||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
|
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
|
||||||
|
|
||||||
const [timeoutDone, setTimeoutDone] = createSignal(true)
|
const [slot, setSlot] = createStore({
|
||||||
|
open: false,
|
||||||
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
|
show: false,
|
||||||
if (working()) return "showing"
|
fade: false,
|
||||||
if (prev === "showing" || !timeoutDone()) return "hiding"
|
|
||||||
return "hidden"
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
let f: number | undefined
|
||||||
if (workingStatus() !== "hiding") return
|
const clear = () => {
|
||||||
|
if (f !== undefined) window.clearTimeout(f)
|
||||||
setTimeoutDone(false)
|
f = undefined
|
||||||
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
|
}
|
||||||
})
|
|
||||||
|
|
||||||
|
onCleanup(clear)
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
working,
|
||||||
|
(on, prev) => {
|
||||||
|
clear()
|
||||||
|
if (on) {
|
||||||
|
setSlot({ open: true, show: true, fade: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (prev) {
|
||||||
|
setSlot({ open: false, show: true, fade: true })
|
||||||
|
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSlot({ open: false, show: false, fade: false })
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
const activeMessageID = createMemo(() => {
|
const activeMessageID = createMemo(() => {
|
||||||
const parentID = pending()?.parentID
|
const parentID = pending()?.parentID
|
||||||
if (parentID) {
|
if (parentID) {
|
||||||
@@ -306,6 +321,7 @@ export function MessageTimeline(props: {
|
|||||||
const [title, setTitle] = createStore({
|
const [title, setTitle] = createStore({
|
||||||
draft: "",
|
draft: "",
|
||||||
editing: false,
|
editing: false,
|
||||||
|
saving: false,
|
||||||
menuOpen: false,
|
menuOpen: false,
|
||||||
pendingRename: false,
|
pendingRename: false,
|
||||||
pendingShare: false,
|
pendingShare: false,
|
||||||
@@ -319,6 +335,38 @@ export function MessageTimeline(props: {
|
|||||||
|
|
||||||
let more: HTMLButtonElement | undefined
|
let more: HTMLButtonElement | undefined
|
||||||
|
|
||||||
|
const [req, setReq] = createStore({ share: false, unshare: false })
|
||||||
|
|
||||||
|
const shareSession = () => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id || req.share) return
|
||||||
|
if (!shareEnabled()) return
|
||||||
|
setReq("share", true)
|
||||||
|
globalSDK.client.session
|
||||||
|
.share({ sessionID: id, directory: sdk.directory })
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("Failed to share session", err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setReq("share", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unshareSession = () => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id || req.unshare) return
|
||||||
|
if (!shareEnabled()) return
|
||||||
|
setReq("unshare", true)
|
||||||
|
globalSDK.client.session
|
||||||
|
.unshare({ sessionID: id, directory: sdk.directory })
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("Failed to unshare session", err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setReq("unshare", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const viewShare = () => {
|
const viewShare = () => {
|
||||||
const url = shareUrl()
|
const url = shareUrl()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
@@ -334,54 +382,6 @@ export function MessageTimeline(props: {
|
|||||||
return language.t("common.requestFailed")
|
return language.t("common.requestFailed")
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareMutation = useMutation(() => ({
|
|
||||||
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
|
|
||||||
onError: (err) => {
|
|
||||||
console.error("Failed to share session", err)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const unshareMutation = useMutation(() => ({
|
|
||||||
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
|
|
||||||
onError: (err) => {
|
|
||||||
console.error("Failed to unshare session", err)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const titleMutation = useMutation(() => ({
|
|
||||||
mutationFn: (input: { id: string; title: string }) =>
|
|
||||||
sdk.client.session.update({ sessionID: input.id, title: input.title }),
|
|
||||||
onSuccess: (_, input) => {
|
|
||||||
sync.set(
|
|
||||||
produce((draft) => {
|
|
||||||
const index = draft.session.findIndex((s) => s.id === input.id)
|
|
||||||
if (index !== -1) draft.session[index].title = input.title
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setTitle("editing", false)
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const shareSession = () => {
|
|
||||||
const id = sessionID()
|
|
||||||
if (!id || shareMutation.isPending) return
|
|
||||||
if (!shareEnabled()) return
|
|
||||||
shareMutation.mutate(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const unshareSession = () => {
|
|
||||||
const id = sessionID()
|
|
||||||
if (!id || unshareMutation.isPending) return
|
|
||||||
if (!shareEnabled()) return
|
|
||||||
unshareMutation.mutate(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -389,6 +389,7 @@ export function MessageTimeline(props: {
|
|||||||
setTitle({
|
setTitle({
|
||||||
draft: "",
|
draft: "",
|
||||||
editing: false,
|
editing: false,
|
||||||
|
saving: false,
|
||||||
menuOpen: false,
|
menuOpen: false,
|
||||||
pendingRename: false,
|
pendingRename: false,
|
||||||
pendingShare: false,
|
pendingShare: false,
|
||||||
@@ -407,22 +408,40 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const closeTitleEditor = () => {
|
const closeTitleEditor = () => {
|
||||||
if (titleMutation.isPending) return
|
if (title.saving) return
|
||||||
setTitle("editing", false)
|
setTitle({ editing: false, saving: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveTitleEditor = () => {
|
const saveTitleEditor = async () => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (titleMutation.isPending) return
|
if (title.saving) return
|
||||||
|
|
||||||
const next = title.draft.trim()
|
const next = title.draft.trim()
|
||||||
if (!next || next === (titleValue() ?? "")) {
|
if (!next || next === (titleValue() ?? "")) {
|
||||||
setTitle("editing", false)
|
setTitle({ editing: false, saving: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
titleMutation.mutate({ id, title: next })
|
setTitle("saving", true)
|
||||||
|
await sdk.client.session
|
||||||
|
.update({ sessionID: id, title: next })
|
||||||
|
.then(() => {
|
||||||
|
sync.set(
|
||||||
|
produce((draft) => {
|
||||||
|
const index = draft.session.findIndex((s) => s.id === id)
|
||||||
|
if (index !== -1) draft.session[index].title = next
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setTitle({ editing: false, saving: false })
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setTitle("saving", false)
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||||
@@ -660,15 +679,17 @@ export function MessageTimeline(props: {
|
|||||||
<div
|
<div
|
||||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||||
style={{
|
style={{
|
||||||
width: working() ? "16px" : "0px",
|
width: slot.open ? "16px" : "0px",
|
||||||
"margin-right": working() ? "8px" : "0px",
|
"margin-right": slot.open ? "8px" : "0px",
|
||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Show when={workingStatus() !== "hidden"}>
|
<Show when={slot.show}>
|
||||||
<div
|
<div
|
||||||
class="transition-opacity duration-200 ease-out"
|
class="transition-opacity duration-200 ease-out"
|
||||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
classList={{
|
||||||
|
"opacity-0": slot.fade,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -691,7 +712,7 @@ export function MessageTimeline(props: {
|
|||||||
titleRef = el
|
titleRef = el
|
||||||
}}
|
}}
|
||||||
value={title.draft}
|
value={title.draft}
|
||||||
disabled={titleMutation.isPending}
|
disabled={title.saving}
|
||||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||||
@@ -842,9 +863,9 @@ export function MessageTimeline(props: {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={shareSession}
|
onClick={shareSession}
|
||||||
disabled={shareMutation.isPending}
|
disabled={req.share}
|
||||||
>
|
>
|
||||||
{shareMutation.isPending
|
{req.share
|
||||||
? language.t("session.share.action.publishing")
|
? language.t("session.share.action.publishing")
|
||||||
: language.t("session.share.action.publish")}
|
: language.t("session.share.action.publish")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -865,9 +886,9 @@ export function MessageTimeline(props: {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="w-full shadow-none border border-border-weak-base"
|
class="w-full shadow-none border border-border-weak-base"
|
||||||
onClick={unshareSession}
|
onClick={unshareSession}
|
||||||
disabled={unshareMutation.isPending}
|
disabled={req.unshare}
|
||||||
>
|
>
|
||||||
{unshareMutation.isPending
|
{req.unshare
|
||||||
? language.t("session.share.action.unpublishing")
|
? language.t("session.share.action.unpublishing")
|
||||||
: language.t("session.share.action.unpublish")}
|
: language.t("session.share.action.unpublish")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -876,7 +897,7 @@ export function MessageTimeline(props: {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={viewShare}
|
onClick={viewShare}
|
||||||
disabled={unshareMutation.isPending}
|
disabled={req.unshare}
|
||||||
>
|
>
|
||||||
{language.t("session.share.action.view")}
|
{language.t("session.share.action.view")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -894,6 +915,7 @@ export function MessageTimeline(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="log"
|
role="log"
|
||||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||||
|
|||||||
@@ -438,10 +438,12 @@ export function SessionSidePanel(props: {
|
|||||||
size={layout.fileTree.width()}
|
size={layout.fileTree.width()}
|
||||||
min={200}
|
min={200}
|
||||||
max={480}
|
max={480}
|
||||||
|
collapseThreshold={160}
|
||||||
onResize={(width) => {
|
onResize={(width) => {
|
||||||
props.size.touch()
|
props.size.touch()
|
||||||
layout.fileTree.resize(width)
|
layout.fileTree.resize(width)
|
||||||
}}
|
}}
|
||||||
|
onCollapse={layout.fileTree.close}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
id: "file.open",
|
id: "file.open",
|
||||||
title: language.t("command.file.open"),
|
title: language.t("command.file.open"),
|
||||||
description: language.t("palette.search.placeholder"),
|
description: language.t("palette.search.placeholder"),
|
||||||
keybind: "mod+k,mod+p",
|
keybind: "mod+p",
|
||||||
slash: "open",
|
slash: "open",
|
||||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
|
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type { Part } from "@opencode-ai/sdk/v2"
|
|
||||||
import { extractPromptFromParts } from "./prompt"
|
|
||||||
|
|
||||||
describe("extractPromptFromParts", () => {
|
|
||||||
test("restores multiple uploaded attachments", () => {
|
|
||||||
const parts = [
|
|
||||||
{
|
|
||||||
id: "text_1",
|
|
||||||
type: "text",
|
|
||||||
text: "check these",
|
|
||||||
sessionID: "ses_1",
|
|
||||||
messageID: "msg_1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "file_1",
|
|
||||||
type: "file",
|
|
||||||
mime: "image/png",
|
|
||||||
url: "data:image/png;base64,AAA",
|
|
||||||
filename: "a.png",
|
|
||||||
sessionID: "ses_1",
|
|
||||||
messageID: "msg_1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "file_2",
|
|
||||||
type: "file",
|
|
||||||
mime: "application/pdf",
|
|
||||||
url: "data:application/pdf;base64,BBB",
|
|
||||||
filename: "b.pdf",
|
|
||||||
sessionID: "ses_1",
|
|
||||||
messageID: "msg_1",
|
|
||||||
},
|
|
||||||
] satisfies Part[]
|
|
||||||
|
|
||||||
const result = extractPromptFromParts(parts)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(3)
|
|
||||||
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
|
|
||||||
expect(result.slice(1)).toMatchObject([
|
|
||||||
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
|
||||||
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -24,13 +24,7 @@ import {
|
|||||||
FreeUsageLimitError,
|
FreeUsageLimitError,
|
||||||
SubscriptionUsageLimitError,
|
SubscriptionUsageLimitError,
|
||||||
} from "./error"
|
} from "./error"
|
||||||
import {
|
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
||||||
buildCostChunk,
|
|
||||||
createBodyConverter,
|
|
||||||
createStreamPartConverter,
|
|
||||||
createResponseConverter,
|
|
||||||
UsageInfo,
|
|
||||||
} from "./provider/provider"
|
|
||||||
import { anthropicHelper } from "./provider/anthropic"
|
import { anthropicHelper } from "./provider/anthropic"
|
||||||
import { googleHelper } from "./provider/google"
|
import { googleHelper } from "./provider/google"
|
||||||
import { openaiHelper } from "./provider/openai"
|
import { openaiHelper } from "./provider/openai"
|
||||||
@@ -96,7 +90,7 @@ export async function handler(
|
|||||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||||
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
||||||
logger.metric({
|
logger.metric({
|
||||||
is_stream: isStream,
|
is_tream: isStream,
|
||||||
session: sessionId,
|
session: sessionId,
|
||||||
request: requestId,
|
request: requestId,
|
||||||
client: ocClient,
|
client: ocClient,
|
||||||
@@ -236,7 +230,7 @@ export async function handler(
|
|||||||
const body = JSON.stringify(
|
const body = JSON.stringify(
|
||||||
responseConverter({
|
responseConverter({
|
||||||
...json,
|
...json,
|
||||||
cost: calculateOccurredCost(billingSource, costInfo),
|
cost: calculateOccuredCost(billingSource, costInfo),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
logger.metric({ response_length: body.length })
|
logger.metric({ response_length: body.length })
|
||||||
@@ -280,8 +274,8 @@ export async function handler(
|
|||||||
await trialLimiter?.track(usageInfo)
|
await trialLimiter?.track(usageInfo)
|
||||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||||
await reload(billingSource, authInfo, costInfo)
|
await reload(billingSource, authInfo, costInfo)
|
||||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
const cost = calculateOccuredCost(billingSource, costInfo)
|
||||||
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
|
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
|
||||||
}
|
}
|
||||||
c.close()
|
c.close()
|
||||||
return
|
return
|
||||||
@@ -824,7 +818,7 @@ export async function handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||||
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
|
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
|
||||||
const isBedrockModelID = providerModel.startsWith("global.anthropic.")
|
const isBedrockModelID = providerModel.startsWith("global.anthropic.")
|
||||||
const isBedrock = isBedrockModelArn || isBedrockModelID
|
const isBedrock = isBedrockModelArn || isBedrockModelID
|
||||||
const isDatabricks = providerModel.startsWith("databricks-claude-")
|
|
||||||
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
|
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
|
||||||
return {
|
return {
|
||||||
format: "anthropic",
|
format: "anthropic",
|
||||||
@@ -29,7 +28,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
|
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
|
||||||
: providerApi + "/messages",
|
: providerApi + "/messages",
|
||||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||||
if (isBedrock || isDatabricks) {
|
if (isBedrock) {
|
||||||
headers.set("Authorization", `Bearer ${apiKey}`)
|
headers.set("Authorization", `Bearer ${apiKey}`)
|
||||||
} else {
|
} else {
|
||||||
headers.set("x-api-key", apiKey)
|
headers.set("x-api-key", apiKey)
|
||||||
@@ -48,14 +47,9 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
model: undefined,
|
model: undefined,
|
||||||
stream: undefined,
|
stream: undefined,
|
||||||
}
|
}
|
||||||
: isDatabricks
|
: {
|
||||||
? {
|
service_tier: "standard_only",
|
||||||
anthropic_version: "bedrock-2023-05-31",
|
}),
|
||||||
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
service_tier: "standard_only",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
createBinaryStreamDecoder: () => {
|
createBinaryStreamDecoder: () => {
|
||||||
if (!isBedrock) return undefined
|
if (!isBedrock) return undefined
|
||||||
@@ -173,6 +167,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
|
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => ({
|
normalizeUsage: (usage: Usage) => ({
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
|||||||
usage = json.usageMetadata
|
usage = json.usageMetadata
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
|
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => {
|
normalizeUsage: (usage: Usage) => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const oaCompatHelper: ProviderHelper = () => ({
|
|||||||
usage = json.usage
|
usage = json.usage
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
|
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => {
|
normalizeUsage: (usage: Usage) => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const openaiHelper: ProviderHelper = () => ({
|
|||||||
usage = json.response.usage
|
usage = json.response.usage
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
|
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => {
|
normalizeUsage: (usage: Usage) => {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
|
|||||||
createUsageParser: () => {
|
createUsageParser: () => {
|
||||||
parse: (chunk: string) => void
|
parse: (chunk: string) => void
|
||||||
retrieve: () => any
|
retrieve: () => any
|
||||||
|
buidlCostChunk: (cost: string) => string
|
||||||
}
|
}
|
||||||
normalizeUsage: (usage: any) => UsageInfo
|
normalizeUsage: (usage: any) => UsageInfo
|
||||||
}
|
}
|
||||||
@@ -161,19 +162,6 @@ export interface CommonChunk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCostChunk(format: ZenData.Format, cost: string): string {
|
|
||||||
switch (format) {
|
|
||||||
case "anthropic":
|
|
||||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
|
||||||
case "openai":
|
|
||||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
|
||||||
case "oa-compat":
|
|
||||||
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
|
|
||||||
default:
|
|
||||||
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||||
return (body: any): any => {
|
return (body: any): any => {
|
||||||
if (from === to) return body
|
if (from === to) return body
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "catalog:",
|
"@cloudflare/workers-types": "catalog:",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "1.3.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"drizzle-kit": "catalog:",
|
"drizzle-kit": "catalog:",
|
||||||
"mysql2": "3.14.4",
|
"mysql2": "3.14.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
|||||||
SHELL ["/bin/bash", "-lc"]
|
SHELL ["/bin/bash", "-lc"]
|
||||||
|
|
||||||
ARG NODE_VERSION=24.4.0
|
ARG NODE_VERSION=24.4.0
|
||||||
ARG BUN_VERSION=1.3.11
|
ARG BUN_VERSION=1.3.5
|
||||||
|
|
||||||
ENV BUN_INSTALL=/opt/bun
|
ENV BUN_INSTALL=/opt/bun
|
||||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export type CommandEvent =
|
|||||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
||||||
|
|
||||||
export type CommandChild = {
|
export type CommandChild = {
|
||||||
pid: number | undefined
|
|
||||||
kill: () => void
|
kill: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +191,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
|||||||
treeKill(child.pid)
|
treeKill(child.pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { events, child: { pid: child.pid, kill }, exit }
|
return { events, child: { kill }, exit }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSqliteProgress(events: EventEmitter, line: string) {
|
function handleSqliteProgress(events: EventEmitter, line: string) {
|
||||||
|
|||||||
@@ -81,17 +81,6 @@ function setupApp() {
|
|||||||
killSidecar()
|
killSidecar()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on("will-quit", () => {
|
|
||||||
killSidecar()
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
||||||
process.on(signal, () => {
|
|
||||||
killSidecar()
|
|
||||||
app.exit(0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
void app.whenReady().then(async () => {
|
void app.whenReady().then(async () => {
|
||||||
// migrate()
|
// migrate()
|
||||||
app.setAsDefaultProtocolClient("opencode")
|
app.setAsDefaultProtocolClient("opencode")
|
||||||
@@ -245,15 +234,8 @@ registerIpcHandlers({
|
|||||||
|
|
||||||
function killSidecar() {
|
function killSidecar() {
|
||||||
if (!sidecar) return
|
if (!sidecar) return
|
||||||
const pid = sidecar.pid
|
|
||||||
sidecar.kill()
|
sidecar.kill()
|
||||||
sidecar = null
|
sidecar = null
|
||||||
// tree-kill is async; also send process group signal as immediate fallback
|
|
||||||
if (pid && process.platform !== "win32") {
|
|
||||||
try {
|
|
||||||
process.kill(-pid, "SIGTERM")
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureLoopbackNoProxy() {
|
function ensureLoopbackNoProxy() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.3.0"
|
version = "1.2.27"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -89,6 +89,8 @@
|
|||||||
"@ai-sdk/xai": "2.0.51",
|
"@ai-sdk/xai": "2.0.51",
|
||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
|
"gitlab-ai-provider": "5.2.2",
|
||||||
|
"opencode-gitlab-auth": "2.0.0",
|
||||||
"@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:",
|
||||||
@@ -101,8 +103,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||||
"@opentui/core": "0.1.90",
|
"@opentui/core": "0.1.87",
|
||||||
"@opentui/solid": "0.1.90",
|
"@opentui/solid": "0.1.87",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -121,7 +123,6 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "5.2.2",
|
|
||||||
"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",
|
||||||
@@ -132,7 +133,6 @@
|
|||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.0.3",
|
"minimatch": "10.0.3",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opencode-gitlab-auth": "2.0.0",
|
|
||||||
"opentui-spinner": "0.0.6",
|
"opentui-spinner": "0.0.6",
|
||||||
"partial-json": "0.1.7",
|
"partial-json": "0.1.7",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
|
|||||||
@@ -199,19 +199,6 @@ for (const item of targets) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Smoke test: only run if binary is for current platform
|
|
||||||
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
|
|
||||||
const binaryPath = `dist/${name}/bin/opencode`
|
|
||||||
console.log(`Running smoke test: ${binaryPath} --version`)
|
|
||||||
try {
|
|
||||||
const versionOutput = await $`${binaryPath} --version`.text()
|
|
||||||
console.log(`Smoke test passed: ${versionOutput.trim()}`)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Smoke test failed for ${name}:`, e)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await $`rm -rf ./dist/${name}/bin/tui`
|
await $`rm -rf ./dist/${name}/bin/tui`
|
||||||
await Bun.file(`dist/${name}/package.json`).write(
|
await Bun.file(`dist/${name}/package.json`).write(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const seed = async () => {
|
|||||||
const { Instance } = await import("../src/project/instance")
|
const { Instance } = await import("../src/project/instance")
|
||||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||||
const { Config } = await import("../src/config/config")
|
const { Config } = await import("../src/config/config")
|
||||||
|
const { disposeRuntime } = await import("../src/effect/runtime")
|
||||||
const { Session } = await import("../src/session")
|
const { Session } = await import("../src/session")
|
||||||
const { MessageID, PartID } = await import("../src/session/schema")
|
const { MessageID, PartID } = await import("../src/session/schema")
|
||||||
const { Project } = await import("../src/project/project")
|
const { Project } = await import("../src/project/project")
|
||||||
@@ -54,6 +55,7 @@ const seed = async () => {
|
|||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await Instance.disposeAll().catch(() => {})
|
await Instance.disposeAll().catch(() => {})
|
||||||
|
await disposeRuntime().catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
|
|||||||
|
|
||||||
## Choose scope
|
## Choose scope
|
||||||
|
|
||||||
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 the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||||
|
|
||||||
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 `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
|
||||||
|
|
||||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
- Shared runtime: config readers, stateless helpers, global clients
|
||||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
|
||||||
|
|
||||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||||
|
|
||||||
## Service shape
|
## Service shape
|
||||||
|
|
||||||
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
|
For a fully migrated module, use the public namespace directly:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export namespace Foo {
|
export namespace Foo {
|
||||||
@@ -28,86 +28,53 @@ export namespace Foo {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
// For instance-scoped services:
|
return Service.of({
|
||||||
const state = yield* InstanceState.make<State>(
|
get: Effect.fn("Foo.get")(function* (id) {
|
||||||
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
return yield* ...
|
||||||
)
|
}),
|
||||||
|
|
||||||
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
// ...
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ get })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Optional: wire dependencies
|
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
|
||||||
|
|
||||||
// Per-service runtime (inside the namespace)
|
|
||||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
|
||||||
|
|
||||||
// Async facade functions
|
|
||||||
export async function get(id: FooID) {
|
|
||||||
return runPromise((svc) => svc.get(id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
|
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
|
||||||
- `runPromise` goes inside the namespace (not exported unless tests need it)
|
- Export `defaultLayer` only when wiring dependencies is useful
|
||||||
- Facade functions are plain `async function` — no `fn()` wrappers
|
- Use the direct namespace form once the module is fully migrated
|
||||||
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
|
|
||||||
- No `Layer.fresh` — InstanceState handles per-directory isolation
|
|
||||||
|
|
||||||
## Schema → Zod interop
|
## Temporary mixed-mode pattern
|
||||||
|
|
||||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
|
Prefer a single namespace whenever possible.
|
||||||
|
|
||||||
|
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { zod } from "@/util/effect-zod"
|
export namespace FooEffect {
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
|
||||||
|
}
|
||||||
|
|
||||||
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(...)
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
See `Auth.ZodInfo` for the canonical example.
|
Then keep the old boundary thin:
|
||||||
|
|
||||||
## InstanceState init patterns
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const cache =
|
export namespace Foo {
|
||||||
yield *
|
export function get(id: FooID) {
|
||||||
InstanceState.make<State>(
|
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
|
||||||
Effect.fn("Foo.state")(function* (ctx) {
|
}
|
||||||
// ... load state ...
|
}
|
||||||
|
|
||||||
yield* Effect.acquireRelease(
|
|
||||||
Effect.sync(() =>
|
|
||||||
Bus.subscribeAll((event) => {
|
|
||||||
/* handle */
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
(unsub) => Effect.sync(unsub),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
/* state */
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
|
Remove the `Effect` suffix when the boundary split is gone.
|
||||||
- **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.
|
|
||||||
|
|
||||||
The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics.
|
|
||||||
|
|
||||||
## Scheduled Tasks
|
## Scheduled Tasks
|
||||||
|
|
||||||
@@ -140,33 +107,32 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
|||||||
|
|
||||||
## Migration checklist
|
## Migration checklist
|
||||||
|
|
||||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
Done now:
|
||||||
|
|
||||||
- [x] `Account` — `account/index.ts`
|
- [x] `AccountEffect` (mixed-mode)
|
||||||
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
|
- [x] `AuthEffect` (mixed-mode)
|
||||||
- [x] `File` — `file/index.ts`
|
- [x] `TruncateEffect` (mixed-mode)
|
||||||
- [x] `FileTime` — `file/time.ts`
|
- [x] `Question`
|
||||||
- [x] `FileWatcher` — `file/watcher.ts`
|
- [x] `PermissionNext`
|
||||||
- [x] `Format` — `format/index.ts`
|
- [x] `ProviderAuth`
|
||||||
- [x] `Installation` — `installation/index.ts`
|
- [x] `FileWatcher`
|
||||||
- [x] `Permission` — `permission/index.ts`
|
- [x] `FileTime`
|
||||||
- [x] `ProviderAuth` — `provider/auth.ts`
|
- [x] `Format`
|
||||||
- [x] `Question` — `question/index.ts`
|
- [x] `Vcs`
|
||||||
- [x] `Skill` — `skill/index.ts`
|
- [x] `Skill`
|
||||||
- [x] `Snapshot` — `snapshot/index.ts`
|
- [x] `Discovery`
|
||||||
- [x] `Truncate` — `tool/truncate.ts`
|
- [x] `File`
|
||||||
- [x] `Vcs` — `project/vcs.ts`
|
- [x] `Snapshot`
|
||||||
- [x] `Discovery` — `skill/discovery.ts`
|
|
||||||
- [x] `SessionStatus`
|
|
||||||
|
|
||||||
Still open and likely worth migrating:
|
Still open and likely worth migrating:
|
||||||
|
|
||||||
- [x] `Plugin`
|
- [x] `Plugin`
|
||||||
- [x] `ToolRegistry`
|
- [ ] `ToolRegistry`
|
||||||
- [ ] `Pty`
|
- [ ] `Pty`
|
||||||
- [ ] `Worktree`
|
- [ ] `Worktree`
|
||||||
|
- [ ] `Installation`
|
||||||
- [ ] `Bus`
|
- [ ] `Bus`
|
||||||
- [x] `Command`
|
- [ ] `Command`
|
||||||
- [ ] `Config`
|
- [ ] `Config`
|
||||||
- [ ] `Session`
|
- [ ] `Session`
|
||||||
- [ ] `SessionProcessor`
|
- [ ] `SessionProcessor`
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||||
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
|
import { AccountRepo, type AccountRow } from "./repo"
|
||||||
|
import {
|
||||||
|
type AccountError,
|
||||||
|
AccessToken,
|
||||||
|
AccountID,
|
||||||
|
DeviceCode,
|
||||||
|
Info,
|
||||||
|
RefreshToken,
|
||||||
|
AccountServiceError,
|
||||||
|
Login,
|
||||||
|
Org,
|
||||||
|
OrgID,
|
||||||
|
PollDenied,
|
||||||
|
PollError,
|
||||||
|
PollExpired,
|
||||||
|
PollPending,
|
||||||
|
type PollResult,
|
||||||
|
PollSlow,
|
||||||
|
PollSuccess,
|
||||||
|
UserCode,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
|
export {
|
||||||
|
AccountID,
|
||||||
|
type AccountError,
|
||||||
|
AccountRepoError,
|
||||||
|
AccountServiceError,
|
||||||
|
AccessToken,
|
||||||
|
RefreshToken,
|
||||||
|
DeviceCode,
|
||||||
|
UserCode,
|
||||||
|
Info,
|
||||||
|
Org,
|
||||||
|
OrgID,
|
||||||
|
Login,
|
||||||
|
PollSuccess,
|
||||||
|
PollPending,
|
||||||
|
PollSlow,
|
||||||
|
PollExpired,
|
||||||
|
PollDenied,
|
||||||
|
PollError,
|
||||||
|
PollResult,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
|
export type AccountOrgs = {
|
||||||
|
account: Info
|
||||||
|
orgs: readonly Org[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||||
|
config: Schema.Record(Schema.String, Schema.Json),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const DurationFromSeconds = Schema.Number.pipe(
|
||||||
|
Schema.decodeTo(Schema.Duration, {
|
||||||
|
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||||
|
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||||
|
access_token: AccessToken,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
expires_in: DurationFromSeconds,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||||
|
device_code: DeviceCode,
|
||||||
|
user_code: UserCode,
|
||||||
|
verification_uri_complete: Schema.String,
|
||||||
|
expires_in: DurationFromSeconds,
|
||||||
|
interval: DurationFromSeconds,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||||
|
access_token: AccessToken,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
token_type: Schema.Literal("Bearer"),
|
||||||
|
expires_in: DurationFromSeconds,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||||
|
error: Schema.String,
|
||||||
|
error_description: Schema.String,
|
||||||
|
}) {
|
||||||
|
toPollResult(): PollResult {
|
||||||
|
if (this.error === "authorization_pending") return new PollPending()
|
||||||
|
if (this.error === "slow_down") return new PollSlow()
|
||||||
|
if (this.error === "expired_token") return new PollExpired()
|
||||||
|
if (this.error === "access_denied") return new PollDenied()
|
||||||
|
return new PollError({ cause: this.error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||||
|
|
||||||
|
class User extends Schema.Class<User>("User")({
|
||||||
|
id: AccountID,
|
||||||
|
email: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||||
|
|
||||||
|
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||||
|
grant_type: Schema.String,
|
||||||
|
device_code: DeviceCode,
|
||||||
|
client_id: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||||
|
grant_type: Schema.String,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
client_id: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const clientId = "opencode-cli"
|
||||||
|
|
||||||
|
const mapAccountServiceError =
|
||||||
|
(message = "Account service operation failed") =>
|
||||||
|
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.mapError((cause) =>
|
||||||
|
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export namespace Account {
|
||||||
|
export interface Interface {
|
||||||
|
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||||
|
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||||
|
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||||
|
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||||
|
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||||
|
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||||
|
readonly config: (
|
||||||
|
accountID: AccountID,
|
||||||
|
orgID: OrgID,
|
||||||
|
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||||
|
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||||
|
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||||
|
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const repo = yield* AccountRepo
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
|
const httpRead = withTransientReadRetry(http)
|
||||||
|
const httpOk = HttpClient.filterStatusOk(http)
|
||||||
|
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||||
|
|
||||||
|
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
|
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||||
|
|
||||||
|
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
|
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||||
|
|
||||||
|
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||||
|
request.pipe(
|
||||||
|
Effect.flatMap((req) => httpOk.execute(req)),
|
||||||
|
mapAccountServiceError("HTTP request failed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||||
|
request.pipe(
|
||||||
|
Effect.flatMap((req) => http.execute(req)),
|
||||||
|
mapAccountServiceError("HTTP request failed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||||
|
|
||||||
|
const response = yield* executeEffectOk(
|
||||||
|
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||||
|
new TokenRefreshRequest({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: row.refresh_token,
|
||||||
|
client_id: clientId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||||
|
|
||||||
|
yield* repo.persistToken({
|
||||||
|
accountID: row.id,
|
||||||
|
accessToken: parsed.access_token,
|
||||||
|
refreshToken: parsed.refresh_token,
|
||||||
|
expiry,
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed.access_token
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||||
|
const maybeAccount = yield* repo.getRow(accountID)
|
||||||
|
if (Option.isNone(maybeAccount)) return Option.none()
|
||||||
|
|
||||||
|
const account = maybeAccount.value
|
||||||
|
const accessToken = yield* resolveToken(account)
|
||||||
|
return Option.some({ account, accessToken })
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||||
|
const response = yield* executeReadOk(
|
||||||
|
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(accessToken),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||||
|
const response = yield* executeReadOk(
|
||||||
|
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(accessToken),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||||
|
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||||
|
const accounts = yield* repo.list()
|
||||||
|
const [errors, results] = yield* Effect.partition(
|
||||||
|
accounts,
|
||||||
|
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||||
|
{ concurrency: 3 },
|
||||||
|
)
|
||||||
|
for (const error of errors) {
|
||||||
|
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||||
|
Effect.annotateLogs({ error: String(error) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
|
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||||
|
const resolved = yield* resolveAccess(accountID)
|
||||||
|
if (Option.isNone(resolved)) return []
|
||||||
|
|
||||||
|
const { account, accessToken } = resolved.value
|
||||||
|
|
||||||
|
return yield* fetchOrgs(account.url, accessToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||||
|
const resolved = yield* resolveAccess(accountID)
|
||||||
|
if (Option.isNone(resolved)) return Option.none()
|
||||||
|
|
||||||
|
const { account, accessToken } = resolved.value
|
||||||
|
|
||||||
|
const response = yield* executeRead(
|
||||||
|
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(accessToken),
|
||||||
|
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status === 404) return Option.none()
|
||||||
|
|
||||||
|
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
return Option.some(parsed.config)
|
||||||
|
})
|
||||||
|
|
||||||
|
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||||
|
const response = yield* executeEffectOk(
|
||||||
|
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
return new Login({
|
||||||
|
code: parsed.device_code,
|
||||||
|
user: parsed.user_code,
|
||||||
|
url: `${server}${parsed.verification_uri_complete}`,
|
||||||
|
server,
|
||||||
|
expiry: parsed.expires_in,
|
||||||
|
interval: parsed.interval,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||||
|
const response = yield* executeEffect(
|
||||||
|
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||||
|
new DeviceTokenRequest({
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
device_code: input.code,
|
||||||
|
client_id: clientId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||||
|
const accessToken = parsed.access_token
|
||||||
|
|
||||||
|
const user = fetchUser(input.server, accessToken)
|
||||||
|
const orgs = fetchOrgs(input.server, accessToken)
|
||||||
|
|
||||||
|
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||||
|
|
||||||
|
// TODO: When there are multiple orgs, let the user choose
|
||||||
|
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||||
|
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||||
|
const refreshToken = parsed.refresh_token
|
||||||
|
|
||||||
|
yield* repo.persistAccount({
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
url: input.server,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiry,
|
||||||
|
orgID: firstOrgID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new PollSuccess({ email: account.email })
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
active: repo.active,
|
||||||
|
list: repo.list,
|
||||||
|
orgsByAccount,
|
||||||
|
remove: repo.remove,
|
||||||
|
use: repo.use,
|
||||||
|
orgs,
|
||||||
|
config,
|
||||||
|
token,
|
||||||
|
login,
|
||||||
|
poll,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||||
|
}
|
||||||
@@ -1,397 +1,34 @@
|
|||||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
||||||
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
||||||
import { AccountRepo, type AccountRow } from "./repo"
|
|
||||||
import {
|
|
||||||
type AccountError,
|
|
||||||
AccessToken,
|
|
||||||
AccountID,
|
|
||||||
DeviceCode,
|
|
||||||
Info,
|
|
||||||
RefreshToken,
|
|
||||||
AccountServiceError,
|
|
||||||
Login,
|
|
||||||
Org,
|
|
||||||
OrgID,
|
|
||||||
PollDenied,
|
|
||||||
PollError,
|
|
||||||
PollExpired,
|
|
||||||
PollPending,
|
|
||||||
type PollResult,
|
|
||||||
PollSlow,
|
|
||||||
PollSuccess,
|
|
||||||
UserCode,
|
|
||||||
} from "./schema"
|
|
||||||
|
|
||||||
export {
|
export { AccessToken, AccountID, OrgID } from "./effect"
|
||||||
AccountID,
|
|
||||||
type AccountError,
|
|
||||||
AccountRepoError,
|
|
||||||
AccountServiceError,
|
|
||||||
AccessToken,
|
|
||||||
RefreshToken,
|
|
||||||
DeviceCode,
|
|
||||||
UserCode,
|
|
||||||
Info,
|
|
||||||
Org,
|
|
||||||
OrgID,
|
|
||||||
Login,
|
|
||||||
PollSuccess,
|
|
||||||
PollPending,
|
|
||||||
PollSlow,
|
|
||||||
PollExpired,
|
|
||||||
PollDenied,
|
|
||||||
PollError,
|
|
||||||
PollResult,
|
|
||||||
} from "./schema"
|
|
||||||
|
|
||||||
export type AccountOrgs = {
|
import { runtime } from "@/effect/runtime"
|
||||||
account: Info
|
|
||||||
orgs: readonly Org[]
|
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||||
|
return runtime.runSync(S.Service.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||||
config: Schema.Record(Schema.String, Schema.Json),
|
return runtime.runPromise(S.Service.use(f))
|
||||||
}) {}
|
|
||||||
|
|
||||||
const DurationFromSeconds = Schema.Number.pipe(
|
|
||||||
Schema.decodeTo(Schema.Duration, {
|
|
||||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
|
||||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
|
||||||
access_token: AccessToken,
|
|
||||||
refresh_token: RefreshToken,
|
|
||||||
expires_in: DurationFromSeconds,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
|
||||||
device_code: DeviceCode,
|
|
||||||
user_code: UserCode,
|
|
||||||
verification_uri_complete: Schema.String,
|
|
||||||
expires_in: DurationFromSeconds,
|
|
||||||
interval: DurationFromSeconds,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
|
||||||
access_token: AccessToken,
|
|
||||||
refresh_token: RefreshToken,
|
|
||||||
token_type: Schema.Literal("Bearer"),
|
|
||||||
expires_in: DurationFromSeconds,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
|
||||||
error: Schema.String,
|
|
||||||
error_description: Schema.String,
|
|
||||||
}) {
|
|
||||||
toPollResult(): PollResult {
|
|
||||||
if (this.error === "authorization_pending") return new PollPending()
|
|
||||||
if (this.error === "slow_down") return new PollSlow()
|
|
||||||
if (this.error === "expired_token") return new PollExpired()
|
|
||||||
if (this.error === "access_denied") return new PollDenied()
|
|
||||||
return new PollError({ cause: this.error })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
|
||||||
|
|
||||||
class User extends Schema.Class<User>("User")({
|
|
||||||
id: AccountID,
|
|
||||||
email: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
|
||||||
|
|
||||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
|
||||||
grant_type: Schema.String,
|
|
||||||
device_code: DeviceCode,
|
|
||||||
client_id: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
|
||||||
grant_type: Schema.String,
|
|
||||||
refresh_token: RefreshToken,
|
|
||||||
client_id: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const clientId = "opencode-cli"
|
|
||||||
|
|
||||||
const mapAccountServiceError =
|
|
||||||
(message = "Account service operation failed") =>
|
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
|
||||||
effect.pipe(
|
|
||||||
Effect.mapError((cause) =>
|
|
||||||
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
export namespace Account {
|
export namespace Account {
|
||||||
export interface Interface {
|
export const Info = Model
|
||||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
export type Info = Model
|
||||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
|
||||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
|
||||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
|
||||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
|
||||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
|
||||||
readonly config: (
|
|
||||||
accountID: AccountID,
|
|
||||||
orgID: OrgID,
|
|
||||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
|
||||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
|
||||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
|
||||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
export function active(): Info | undefined {
|
||||||
|
return Option.getOrUndefined(runSync((service) => service.active()))
|
||||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const repo = yield* AccountRepo
|
|
||||||
const http = yield* HttpClient.HttpClient
|
|
||||||
const httpRead = withTransientReadRetry(http)
|
|
||||||
const httpOk = HttpClient.filterStatusOk(http)
|
|
||||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
|
||||||
|
|
||||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
|
||||||
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
|
||||||
|
|
||||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
|
||||||
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
|
||||||
|
|
||||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
|
||||||
request.pipe(
|
|
||||||
Effect.flatMap((req) => httpOk.execute(req)),
|
|
||||||
mapAccountServiceError("HTTP request failed"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
|
||||||
request.pipe(
|
|
||||||
Effect.flatMap((req) => http.execute(req)),
|
|
||||||
mapAccountServiceError("HTTP request failed"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
|
||||||
const now = yield* Clock.currentTimeMillis
|
|
||||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
|
||||||
|
|
||||||
const response = yield* executeEffectOk(
|
|
||||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
|
||||||
new TokenRefreshRequest({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: row.refresh_token,
|
|
||||||
client_id: clientId,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
|
||||||
|
|
||||||
yield* repo.persistToken({
|
|
||||||
accountID: row.id,
|
|
||||||
accessToken: parsed.access_token,
|
|
||||||
refreshToken: parsed.refresh_token,
|
|
||||||
expiry,
|
|
||||||
})
|
|
||||||
|
|
||||||
return parsed.access_token
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
|
||||||
const maybeAccount = yield* repo.getRow(accountID)
|
|
||||||
if (Option.isNone(maybeAccount)) return Option.none()
|
|
||||||
|
|
||||||
const account = maybeAccount.value
|
|
||||||
const accessToken = yield* resolveToken(account)
|
|
||||||
return Option.some({ account, accessToken })
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
|
||||||
const response = yield* executeReadOk(
|
|
||||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.bearerToken(accessToken),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
|
||||||
const response = yield* executeReadOk(
|
|
||||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.bearerToken(accessToken),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
|
||||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
|
||||||
const accounts = yield* repo.list()
|
|
||||||
const [errors, results] = yield* Effect.partition(
|
|
||||||
accounts,
|
|
||||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
|
||||||
{ concurrency: 3 },
|
|
||||||
)
|
|
||||||
for (const error of errors) {
|
|
||||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
|
||||||
Effect.annotateLogs({ error: String(error) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
})
|
|
||||||
|
|
||||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
|
||||||
const resolved = yield* resolveAccess(accountID)
|
|
||||||
if (Option.isNone(resolved)) return []
|
|
||||||
|
|
||||||
const { account, accessToken } = resolved.value
|
|
||||||
|
|
||||||
return yield* fetchOrgs(account.url, accessToken)
|
|
||||||
})
|
|
||||||
|
|
||||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
|
||||||
const resolved = yield* resolveAccess(accountID)
|
|
||||||
if (Option.isNone(resolved)) return Option.none()
|
|
||||||
|
|
||||||
const { account, accessToken } = resolved.value
|
|
||||||
|
|
||||||
const response = yield* executeRead(
|
|
||||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.bearerToken(accessToken),
|
|
||||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (response.status === 404) return Option.none()
|
|
||||||
|
|
||||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
return Option.some(parsed.config)
|
|
||||||
})
|
|
||||||
|
|
||||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
|
||||||
const response = yield* executeEffectOk(
|
|
||||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
return new Login({
|
|
||||||
code: parsed.device_code,
|
|
||||||
user: parsed.user_code,
|
|
||||||
url: `${server}${parsed.verification_uri_complete}`,
|
|
||||||
server,
|
|
||||||
expiry: parsed.expires_in,
|
|
||||||
interval: parsed.interval,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
|
||||||
const response = yield* executeEffect(
|
|
||||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
|
||||||
new DeviceTokenRequest({
|
|
||||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
||||||
device_code: input.code,
|
|
||||||
client_id: clientId,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
|
||||||
const accessToken = parsed.access_token
|
|
||||||
|
|
||||||
const user = fetchUser(input.server, accessToken)
|
|
||||||
const orgs = fetchOrgs(input.server, accessToken)
|
|
||||||
|
|
||||||
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
|
||||||
|
|
||||||
// TODO: When there are multiple orgs, let the user choose
|
|
||||||
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
|
||||||
|
|
||||||
const now = yield* Clock.currentTimeMillis
|
|
||||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
|
||||||
const refreshToken = parsed.refresh_token
|
|
||||||
|
|
||||||
yield* repo.persistAccount({
|
|
||||||
id: account.id,
|
|
||||||
email: account.email,
|
|
||||||
url: input.server,
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
expiry,
|
|
||||||
orgID: firstOrgID,
|
|
||||||
})
|
|
||||||
|
|
||||||
return new PollSuccess({ email: account.email })
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
active: repo.active,
|
|
||||||
list: repo.list,
|
|
||||||
orgsByAccount,
|
|
||||||
remove: repo.remove,
|
|
||||||
use: repo.use,
|
|
||||||
orgs,
|
|
||||||
config,
|
|
||||||
token,
|
|
||||||
login,
|
|
||||||
poll,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
|
||||||
|
|
||||||
export const runPromise = makeRunPromise(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function active(): Promise<Info | undefined> {
|
|
||||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
||||||
const cfg = await runPromise((service) => service.config(accountID, orgID))
|
const config = await runPromise((service) => service.config(accountID, orgID))
|
||||||
return Option.getOrUndefined(cfg)
|
return Option.getOrUndefined(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
||||||
const t = await runPromise((service) => service.token(accountID))
|
const token = await runPromise((service) => service.token(accountID))
|
||||||
return Option.getOrUndefined(t)
|
return Option.getOrUndefined(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
|||||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||||
import PROMPT_TITLE from "./prompt/title.txt"
|
import PROMPT_TITLE from "./prompt/title.txt"
|
||||||
import { Permission } from "@/permission"
|
import { PermissionNext } from "@/permission"
|
||||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -32,7 +32,7 @@ export namespace Agent {
|
|||||||
topP: z.number().optional(),
|
topP: z.number().optional(),
|
||||||
temperature: z.number().optional(),
|
temperature: z.number().optional(),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
permission: Permission.Ruleset,
|
permission: PermissionNext.Ruleset,
|
||||||
model: z
|
model: z
|
||||||
.object({
|
.object({
|
||||||
modelID: ModelID.zod,
|
modelID: ModelID.zod,
|
||||||
@@ -54,7 +54,7 @@ export namespace Agent {
|
|||||||
|
|
||||||
const skillDirs = await Skill.dirs()
|
const skillDirs = await Skill.dirs()
|
||||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||||
const defaults = Permission.fromConfig({
|
const defaults = PermissionNext.fromConfig({
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
doom_loop: "ask",
|
doom_loop: "ask",
|
||||||
external_directory: {
|
external_directory: {
|
||||||
@@ -72,16 +72,16 @@ export namespace Agent {
|
|||||||
"*.env.example": "allow",
|
"*.env.example": "allow",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
const user = PermissionNext.fromConfig(cfg.permission ?? {})
|
||||||
|
|
||||||
const result: Record<string, Info> = {
|
const result: Record<string, Info> = {
|
||||||
build: {
|
build: {
|
||||||
name: "build",
|
name: "build",
|
||||||
description: "The default agent. Executes tools based on configured permissions.",
|
description: "The default agent. Executes tools based on configured permissions.",
|
||||||
options: {},
|
options: {},
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
question: "allow",
|
question: "allow",
|
||||||
plan_enter: "allow",
|
plan_enter: "allow",
|
||||||
}),
|
}),
|
||||||
@@ -94,9 +94,9 @@ export namespace Agent {
|
|||||||
name: "plan",
|
name: "plan",
|
||||||
description: "Plan mode. Disallows all edit tools.",
|
description: "Plan mode. Disallows all edit tools.",
|
||||||
options: {},
|
options: {},
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
question: "allow",
|
question: "allow",
|
||||||
plan_exit: "allow",
|
plan_exit: "allow",
|
||||||
external_directory: {
|
external_directory: {
|
||||||
@@ -116,9 +116,9 @@ export namespace Agent {
|
|||||||
general: {
|
general: {
|
||||||
name: "general",
|
name: "general",
|
||||||
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
todoread: "deny",
|
todoread: "deny",
|
||||||
todowrite: "deny",
|
todowrite: "deny",
|
||||||
}),
|
}),
|
||||||
@@ -130,9 +130,9 @@ export namespace Agent {
|
|||||||
},
|
},
|
||||||
explore: {
|
explore: {
|
||||||
name: "explore",
|
name: "explore",
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
grep: "allow",
|
grep: "allow",
|
||||||
glob: "allow",
|
glob: "allow",
|
||||||
@@ -161,9 +161,9 @@ export namespace Agent {
|
|||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
prompt: PROMPT_COMPACTION,
|
prompt: PROMPT_COMPACTION,
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
@@ -177,9 +177,9 @@ export namespace Agent {
|
|||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
@@ -192,9 +192,9 @@ export namespace Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
@@ -213,7 +213,7 @@ export namespace Agent {
|
|||||||
item = result[key] = {
|
item = result[key] = {
|
||||||
name: key,
|
name: key,
|
||||||
mode: "all",
|
mode: "all",
|
||||||
permission: Permission.merge(defaults, user),
|
permission: PermissionNext.merge(defaults, user),
|
||||||
options: {},
|
options: {},
|
||||||
native: false,
|
native: false,
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ export namespace Agent {
|
|||||||
item.name = value.name ?? item.name
|
item.name = value.name ?? item.name
|
||||||
item.steps = value.steps ?? item.steps
|
item.steps = value.steps ?? item.steps
|
||||||
item.options = mergeDeep(item.options, value.options ?? {})
|
item.options = mergeDeep(item.options, value.options ?? {})
|
||||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||||
@@ -242,9 +242,9 @@ export namespace Agent {
|
|||||||
})
|
})
|
||||||
if (explicit) continue
|
if (explicit) continue
|
||||||
|
|
||||||
result[name].permission = Permission.merge(
|
result[name].permission = PermissionNext.merge(
|
||||||
result[name].permission,
|
result[name].permission,
|
||||||
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
|
||||||
|
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||||
|
|
||||||
|
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||||
|
type: Schema.Literal("oauth"),
|
||||||
|
refresh: Schema.String,
|
||||||
|
access: Schema.String,
|
||||||
|
expires: Schema.Number,
|
||||||
|
accountId: Schema.optional(Schema.String),
|
||||||
|
enterpriseUrl: Schema.optional(Schema.String),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||||
|
type: Schema.Literal("api"),
|
||||||
|
key: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||||
|
type: Schema.Literal("wellknown"),
|
||||||
|
key: Schema.String,
|
||||||
|
token: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
||||||
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
|
|
||||||
|
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||||
|
message: Schema.String,
|
||||||
|
cause: Schema.optional(Schema.Defect),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const file = path.join(Global.Path.data, "auth.json")
|
||||||
|
|
||||||
|
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||||
|
|
||||||
|
export namespace Auth {
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||||
|
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||||
|
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||||
|
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const decode = Schema.decodeUnknownOption(Info)
|
||||||
|
|
||||||
|
const all = Effect.fn("Auth.all")(() =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||||
|
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||||
|
},
|
||||||
|
catch: fail("Failed to read auth data"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||||
|
return (yield* all())[providerID]
|
||||||
|
})
|
||||||
|
|
||||||
|
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||||
|
const norm = key.replace(/\/+$/, "")
|
||||||
|
const data = yield* all()
|
||||||
|
if (norm !== key) delete data[key]
|
||||||
|
delete data[norm + "/"]
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||||
|
catch: fail("Failed to write auth data"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||||
|
const norm = key.replace(/\/+$/, "")
|
||||||
|
const data = yield* all()
|
||||||
|
delete data[key]
|
||||||
|
delete data[norm]
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||||
|
catch: fail("Failed to write auth data"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, set, remove })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,101 +1,43 @@
|
|||||||
import path from "path"
|
import { Effect } from "effect"
|
||||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
import z from "zod"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { runtime } from "@/effect/runtime"
|
||||||
import { zod } from "@/util/effect-zod"
|
import * as S from "./effect"
|
||||||
import { Global } from "../global"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
|
|
||||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
export { OAUTH_DUMMY_KEY } from "./effect"
|
||||||
|
|
||||||
const file = path.join(Global.Path.data, "auth.json")
|
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||||
|
return runtime.runPromise(S.Auth.Service.use(f))
|
||||||
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
|
}
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
export const Oauth = z
|
||||||
type: Schema.Literal("oauth"),
|
.object({
|
||||||
refresh: Schema.String,
|
type: z.literal("oauth"),
|
||||||
access: Schema.String,
|
refresh: z.string(),
|
||||||
expires: Schema.Number,
|
access: z.string(),
|
||||||
accountId: Schema.optional(Schema.String),
|
expires: z.number(),
|
||||||
enterpriseUrl: Schema.optional(Schema.String),
|
accountId: z.string().optional(),
|
||||||
}) {}
|
enterpriseUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "OAuth" })
|
||||||
|
|
||||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
export const Api = z
|
||||||
type: Schema.Literal("api"),
|
.object({
|
||||||
key: Schema.String,
|
type: z.literal("api"),
|
||||||
}) {}
|
key: z.string(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "ApiAuth" })
|
||||||
|
|
||||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
export const WellKnown = z
|
||||||
type: Schema.Literal("wellknown"),
|
.object({
|
||||||
key: Schema.String,
|
type: z.literal("wellknown"),
|
||||||
token: Schema.String,
|
key: z.string(),
|
||||||
}) {}
|
token: z.string(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "WellKnownAuth" })
|
||||||
|
|
||||||
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
|
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
|
||||||
export const Info = Object.assign(_Info, { zod: zod(_Info) })
|
export type Info = z.infer<typeof Info>
|
||||||
export type Info = Schema.Schema.Type<typeof _Info>
|
|
||||||
|
|
||||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
|
||||||
message: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
|
||||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
|
||||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
|
||||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const decode = Schema.decodeUnknownOption(Info)
|
|
||||||
|
|
||||||
const all = Effect.fn("Auth.all")(() =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: async () => {
|
|
||||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
|
||||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
|
||||||
},
|
|
||||||
catch: fail("Failed to read auth data"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
|
||||||
return (yield* all())[providerID]
|
|
||||||
})
|
|
||||||
|
|
||||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
|
||||||
const norm = key.replace(/\/+$/, "")
|
|
||||||
const data = yield* all()
|
|
||||||
if (norm !== key) delete data[key]
|
|
||||||
delete data[norm + "/"]
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
|
||||||
catch: fail("Failed to write auth data"),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
|
||||||
const norm = key.replace(/\/+$/, "")
|
|
||||||
const data = yield* all()
|
|
||||||
delete data[key]
|
|
||||||
delete data[norm]
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
|
||||||
catch: fail("Failed to write auth data"),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, all, set, remove })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(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))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { ZodType } from "zod"
|
import type { ZodObject, ZodRawShape } from "zod"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
export namespace BusEvent {
|
export namespace BusEvent {
|
||||||
@@ -9,7 +9,7 @@ export namespace BusEvent {
|
|||||||
|
|
||||||
const registry = new Map<string, Definition>()
|
const registry = new Map<string, Definition>()
|
||||||
|
|
||||||
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
|
||||||
const result = {
|
const result = {
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
|
|||||||
event: [
|
event: [
|
||||||
{
|
{
|
||||||
directory?: string
|
directory?: string
|
||||||
payload: any
|
payload: { type: string; properties: Record<string, unknown> }
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { Duration, Effect, Match, Option } from "effect"
|
import { Duration, Effect, Match, Option } from "effect"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
import { runtime } from "@/effect/runtime"
|
||||||
|
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
||||||
import { type AccountError } from "@/account/schema"
|
import { type AccountError } from "@/account/schema"
|
||||||
import * as Prompt from "../effect/prompt"
|
import * as Prompt from "../effect/prompt"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
@@ -159,7 +160,7 @@ export const LoginCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => loginEffect(args.url))
|
await runtime.runPromise(loginEffect(args.url))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ export const LogoutCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => logoutEffect(args.email))
|
await runtime.runPromise(logoutEffect(args.email))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ export const SwitchCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => switchEffect())
|
await runtime.runPromise(switchEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -191,7 +192,7 @@ export const OrgsCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => orgsEffect())
|
await runtime.runPromise(orgsEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
|
|||||||
import { MessageID, PartID } from "../../../session/schema"
|
import { MessageID, PartID } from "../../../session/schema"
|
||||||
import { ToolRegistry } from "../../../tool/registry"
|
import { ToolRegistry } from "../../../tool/registry"
|
||||||
import { Instance } from "../../../project/instance"
|
import { Instance } from "../../../project/instance"
|
||||||
import { Permission } from "../../../permission"
|
import { PermissionNext } from "../../../permission"
|
||||||
import { iife } from "../../../util/iife"
|
import { iife } from "../../../util/iife"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
@@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||||
const disabled = Permission.disabled(
|
const disabled = PermissionNext.disabled(
|
||||||
availableTools.map((tool) => tool.id),
|
availableTools.map((tool) => tool.id),
|
||||||
agent.permission,
|
agent.permission,
|
||||||
)
|
)
|
||||||
@@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
|
|||||||
}
|
}
|
||||||
await Session.updateMessage(message)
|
await Session.updateMessage(message)
|
||||||
|
|
||||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
@@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
|
|||||||
abort: new AbortController().signal,
|
abort: new AbortController().signal,
|
||||||
messages: [],
|
messages: [],
|
||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
|
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
||||||
for (const pattern of req.patterns) {
|
for (const pattern of req.patterns) {
|
||||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
|
||||||
if (rule.action === "deny") {
|
if (rule.action === "deny") {
|
||||||
throw new Permission.DeniedError({ ruleset })
|
throw new PermissionNext.DeniedError({ ruleset })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
|
|||||||
import { Server } from "../../server/server"
|
import { Server } from "../../server/server"
|
||||||
import { Provider } from "../../provider/provider"
|
import { Provider } from "../../provider/provider"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Permission } from "../../permission"
|
import { PermissionNext } from "../../permission"
|
||||||
import { Tool } from "../../tool/tool"
|
import { Tool } from "../../tool/tool"
|
||||||
import { GlobTool } from "../../tool/glob"
|
import { GlobTool } from "../../tool/glob"
|
||||||
import { GrepTool } from "../../tool/grep"
|
import { GrepTool } from "../../tool/grep"
|
||||||
@@ -354,7 +354,7 @@ export const RunCommand = cmd({
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules: Permission.Ruleset = [
|
const rules: PermissionNext.Ruleset = [
|
||||||
{
|
{
|
||||||
permission: "question",
|
permission: "question",
|
||||||
action: "deny",
|
action: "deny",
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { MouseButton, TextAttributes } from "@opentui/core"
|
|||||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||||
|
import { Installation } from "@/installation"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import semver from "semver"
|
|
||||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||||
@@ -29,7 +29,6 @@ import { PromptHistoryProvider } from "./component/prompt/history"
|
|||||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||||
import { PromptStashProvider } from "./component/prompt/stash"
|
import { PromptStashProvider } from "./component/prompt/stash"
|
||||||
import { DialogAlert } from "./ui/dialog-alert"
|
import { DialogAlert } from "./ui/dialog-alert"
|
||||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
|
||||||
import { ToastProvider, useToast } from "./ui/toast"
|
import { ToastProvider, useToast } from "./ui/toast"
|
||||||
import { ExitProvider, useExit } from "./context/exit"
|
import { ExitProvider, useExit } from "./context/exit"
|
||||||
import { Session as SessionApi } from "@/session"
|
import { Session as SessionApi } from "@/session"
|
||||||
@@ -104,7 +103,6 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { Installation } from "@/installation"
|
|
||||||
|
|
||||||
export function tui(input: {
|
export function tui(input: {
|
||||||
url: string
|
url: string
|
||||||
@@ -731,51 +729,13 @@ function App() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on("installation.update-available", async (evt) => {
|
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
|
||||||
const version = evt.properties.version
|
|
||||||
|
|
||||||
const skipped = kv.get("skipped_version")
|
|
||||||
if (skipped && !semver.gt(version, skipped)) return
|
|
||||||
|
|
||||||
const choice = await DialogConfirm.show(
|
|
||||||
dialog,
|
|
||||||
`Update Available`,
|
|
||||||
`A new release v${version} is available. Would you like to update now?`,
|
|
||||||
"skip",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (choice === false) {
|
|
||||||
kv.set("skipped_version", version)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (choice !== true) return
|
|
||||||
|
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "info",
|
variant: "info",
|
||||||
message: `Updating to v${version}...`,
|
title: "Update Available",
|
||||||
duration: 30000,
|
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
|
||||||
|
duration: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await sdk.client.global.upgrade({ target: version })
|
|
||||||
|
|
||||||
if (result.error || !result.data?.success) {
|
|
||||||
toast.show({
|
|
||||||
variant: "error",
|
|
||||||
title: "Update Failed",
|
|
||||||
message: "Update failed",
|
|
||||||
duration: 10000,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await DialogAlert.show(
|
|
||||||
dialog,
|
|
||||||
"Update Complete",
|
|
||||||
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
|
|
||||||
)
|
|
||||||
|
|
||||||
exit()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
|
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
|
||||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||||
import "opentui-spinner/solid"
|
import "opentui-spinner/solid"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -934,7 +934,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
// Normalize line endings at the boundary
|
// Normalize line endings at the boundary
|
||||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||||
// Replace CRLF first, then any remaining CR
|
// Replace CRLF first, then any remaining CR
|
||||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||||
const pastedContent = normalizedText.trim()
|
const pastedContent = normalizedText.trim()
|
||||||
if (!pastedContent) {
|
if (!pastedContent) {
|
||||||
command.trigger("prompt.paste")
|
command.trigger("prompt.paste")
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
|||||||
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||||
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
|
const transparent = RGBA.fromInts(0, 0, 0, 0)
|
||||||
const isDark = mode == "dark"
|
const isDark = mode == "dark"
|
||||||
|
|
||||||
const col = (i: number) => {
|
const col = (i: number) => {
|
||||||
|
|||||||
@@ -1465,8 +1465,6 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
|||||||
streaming={true}
|
streaming={true}
|
||||||
content={props.part.text.trim()}
|
content={props.part.text.trim()}
|
||||||
conceal={ctx.conceal()}
|
conceal={ctx.conceal()}
|
||||||
fg={theme.markdownText}
|
|
||||||
bg={theme.background}
|
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||||
@@ -1669,7 +1667,6 @@ function InlineTool(props: {
|
|||||||
|
|
||||||
const denied = createMemo(
|
const denied = createMemo(
|
||||||
() =>
|
() =>
|
||||||
error()?.includes("QuestionRejectedError") ||
|
|
||||||
error()?.includes("rejected permission") ||
|
error()?.includes("rejected permission") ||
|
||||||
error()?.includes("specified a rule") ||
|
error()?.includes("specified a rule") ||
|
||||||
error()?.includes("user dismissed"),
|
error()?.includes("user dismissed"),
|
||||||
|
|||||||
@@ -11,11 +11,8 @@ export type DialogConfirmProps = {
|
|||||||
message: string
|
message: string
|
||||||
onConfirm?: () => void
|
onConfirm?: () => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
label?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DialogConfirmResult = boolean | undefined
|
|
||||||
|
|
||||||
export function DialogConfirm(props: DialogConfirmProps) {
|
export function DialogConfirm(props: DialogConfirmProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
@@ -48,7 +45,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||||||
<text fg={theme.textMuted}>{props.message}</text>
|
<text fg={theme.textMuted}>{props.message}</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||||
<For each={["cancel", "confirm"] as const}>
|
<For each={["cancel", "confirm"]}>
|
||||||
{(key) => (
|
{(key) => (
|
||||||
<box
|
<box
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -61,7 +58,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
|
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
|
||||||
{Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
|
{Locale.titlecase(key)}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
@@ -71,8 +68,8 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
|
DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => {
|
||||||
return new Promise<DialogConfirmResult>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
dialog.replace(
|
dialog.replace(
|
||||||
() => (
|
() => (
|
||||||
<DialogConfirm
|
<DialogConfirm
|
||||||
@@ -80,10 +77,9 @@ DialogConfirm.show = (dialog: DialogContext, title: string, message: string, lab
|
|||||||
message={message}
|
message={message}
|
||||||
onConfirm={() => resolve(true)}
|
onConfirm={() => resolve(true)}
|
||||||
onCancel={() => resolve(false)}
|
onCancel={() => resolve(false)}
|
||||||
label={label}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
() => resolve(undefined),
|
() => resolve(false),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,12 @@ export async function upgrade() {
|
|||||||
const method = await Installation.method()
|
const method = await Installation.method()
|
||||||
const latest = await Installation.latest(method).catch(() => {})
|
const latest = await Installation.latest(method).catch(() => {})
|
||||||
if (!latest) return
|
if (!latest) return
|
||||||
|
if (Installation.VERSION === latest) return
|
||||||
|
|
||||||
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
|
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) {
|
||||||
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (config.autoupdate === "notify") {
|
||||||
if (Installation.VERSION === latest) return
|
|
||||||
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
|
||||||
|
|
||||||
const kind = Installation.getReleaseType(Installation.VERSION, latest)
|
|
||||||
|
|
||||||
if (config.autoupdate === "notify" || kind !== "patch") {
|
|
||||||
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
|
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { MCP } from "../mcp"
|
import { Instance } from "../project/instance"
|
||||||
import { Skill } from "../skill"
|
import { Identifier } from "../id/id"
|
||||||
import { Log } from "../util/log"
|
|
||||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||||
import PROMPT_REVIEW from "./template/review.txt"
|
import PROMPT_REVIEW from "./template/review.txt"
|
||||||
|
import { MCP } from "../mcp"
|
||||||
|
import { Skill } from "../skill"
|
||||||
|
|
||||||
export namespace Command {
|
export namespace Command {
|
||||||
const log = Log.create({ service: "command" })
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
commands: Record<string, Info>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Executed: BusEvent.define(
|
Executed: BusEvent.define(
|
||||||
"command.executed",
|
"command.executed",
|
||||||
@@ -50,7 +42,7 @@ export namespace Command {
|
|||||||
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
||||||
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||||
|
|
||||||
export function hints(template: string) {
|
export function hints(template: string): string[] {
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
const numbered = template.match(/\$\d+/g)
|
const numbered = template.match(/\$\d+/g)
|
||||||
if (numbered) {
|
if (numbered) {
|
||||||
@@ -65,121 +57,95 @@ export namespace Command {
|
|||||||
REVIEW: "review",
|
REVIEW: "review",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export interface Interface {
|
const state = Instance.state(async () => {
|
||||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
const cfg = await Config.get()
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
|
const result: Record<string, Info> = {
|
||||||
|
[Default.INIT]: {
|
||||||
|
name: Default.INIT,
|
||||||
|
description: "create/update AGENTS.md",
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
|
||||||
|
},
|
||||||
|
hints: hints(PROMPT_INITIALIZE),
|
||||||
|
},
|
||||||
|
[Default.REVIEW]: {
|
||||||
|
name: Default.REVIEW,
|
||||||
|
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
|
||||||
|
},
|
||||||
|
subtask: true,
|
||||||
|
hints: hints(PROMPT_REVIEW),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
||||||
Service,
|
result[name] = {
|
||||||
Effect.gen(function* () {
|
name,
|
||||||
const init = Effect.fn("Command.state")(function* (ctx) {
|
agent: command.agent,
|
||||||
const cfg = yield* Effect.promise(() => Config.get())
|
model: command.model,
|
||||||
const commands: Record<string, Info> = {}
|
description: command.description,
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return command.template
|
||||||
|
},
|
||||||
|
subtask: command.subtask,
|
||||||
|
hints: hints(command.template),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
||||||
|
result[name] = {
|
||||||
|
name,
|
||||||
|
source: "mcp",
|
||||||
|
description: prompt.description,
|
||||||
|
get template() {
|
||||||
|
// since a getter can't be async we need to manually return a promise here
|
||||||
|
return new Promise<string>(async (resolve, reject) => {
|
||||||
|
const template = await MCP.getPrompt(
|
||||||
|
prompt.client,
|
||||||
|
prompt.name,
|
||||||
|
prompt.arguments
|
||||||
|
? // substitute each argument with $1, $2, etc.
|
||||||
|
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
|
||||||
|
: {},
|
||||||
|
).catch(reject)
|
||||||
|
resolve(
|
||||||
|
template?.messages
|
||||||
|
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||||
|
.join("\n") || "",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
commands[Default.INIT] = {
|
// Add skills as invokable commands
|
||||||
name: Default.INIT,
|
for (const skill of await Skill.all()) {
|
||||||
description: "create/update AGENTS.md",
|
// Skip if a command with this name already exists
|
||||||
source: "command",
|
if (result[skill.name]) continue
|
||||||
get template() {
|
result[skill.name] = {
|
||||||
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
name: skill.name,
|
||||||
},
|
description: skill.description,
|
||||||
hints: hints(PROMPT_INITIALIZE),
|
source: "skill",
|
||||||
}
|
get template() {
|
||||||
commands[Default.REVIEW] = {
|
return skill.content
|
||||||
name: Default.REVIEW,
|
},
|
||||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
hints: [],
|
||||||
source: "command",
|
}
|
||||||
get template() {
|
}
|
||||||
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
|
|
||||||
},
|
|
||||||
subtask: true,
|
|
||||||
hints: hints(PROMPT_REVIEW),
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
return result
|
||||||
commands[name] = {
|
})
|
||||||
name,
|
|
||||||
agent: command.agent,
|
|
||||||
model: command.model,
|
|
||||||
description: command.description,
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return command.template
|
|
||||||
},
|
|
||||||
subtask: command.subtask,
|
|
||||||
hints: hints(command.template),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
|
|
||||||
commands[name] = {
|
|
||||||
name,
|
|
||||||
source: "mcp",
|
|
||||||
description: prompt.description,
|
|
||||||
get template() {
|
|
||||||
return new Promise<string>(async (resolve, reject) => {
|
|
||||||
const template = await MCP.getPrompt(
|
|
||||||
prompt.client,
|
|
||||||
prompt.name,
|
|
||||||
prompt.arguments
|
|
||||||
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
|
|
||||||
: {},
|
|
||||||
).catch(reject)
|
|
||||||
resolve(
|
|
||||||
template?.messages
|
|
||||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
|
||||||
.join("\n") || "",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const skill of yield* Effect.promise(() => Skill.all())) {
|
|
||||||
if (commands[skill.name]) continue
|
|
||||||
commands[skill.name] = {
|
|
||||||
name: skill.name,
|
|
||||||
description: skill.description,
|
|
||||||
source: "skill",
|
|
||||||
get template() {
|
|
||||||
return skill.content
|
|
||||||
},
|
|
||||||
hints: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
commands,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const cache = yield* InstanceState.make<State>((ctx) => init(ctx))
|
|
||||||
|
|
||||||
const get = Effect.fn("Command.get")(function* (name: string) {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
return state.commands[name]
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Command.list")(function* () {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
return Object.values(state.commands)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export async function get(name: string) {
|
export async function get(name: string) {
|
||||||
return runPromise((svc) => svc.get(name))
|
return state().then((x) => x[name])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return runPromise((svc) => svc.list())
|
return state().then((x) => Object.values(x))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export namespace Config {
|
|||||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = await Account.active()
|
const active = Account.active()
|
||||||
if (active?.active_org_id) {
|
if (active?.active_org_id) {
|
||||||
try {
|
try {
|
||||||
const [config, token] = await Promise.all([
|
const [config, token] = await Promise.all([
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export namespace Workspace {
|
|||||||
await parseSSE(res.body, stop, (event) => {
|
await parseSSE(res.body, stop, (event) => {
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: space.id,
|
directory: space.id,
|
||||||
payload: event,
|
payload: event as { type: string; properties: Record<string, unknown> },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Wait 250ms and retry if SSE connection fails
|
// Wait 250ms and retry if SSE connection fails
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Effect, ScopedCache, Scope } from "effect"
|
|
||||||
import { Instance, type Shape } from "@/project/instance"
|
|
||||||
import { registerDisposer } from "./instance-registry"
|
|
||||||
|
|
||||||
const TypeId = "~opencode/InstanceState"
|
|
||||||
|
|
||||||
export interface InstanceState<A, E = never, R = never> {
|
|
||||||
readonly [TypeId]: typeof TypeId
|
|
||||||
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace InstanceState {
|
|
||||||
export const make = <A, E = never, R = never>(
|
|
||||||
init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
|
|
||||||
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
|
||||||
capacity: Number.POSITIVE_INFINITY,
|
|
||||||
lookup: () => init(Instance.current),
|
|
||||||
})
|
|
||||||
|
|
||||||
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
|
||||||
|
|
||||||
return {
|
|
||||||
[TypeId]: TypeId,
|
|
||||||
cache,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
|
||||||
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
|
|
||||||
|
|
||||||
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
|
|
||||||
Effect.map(get(self), select)
|
|
||||||
|
|
||||||
export const useEffect = <A, E, R, B, E2, R2>(
|
|
||||||
self: InstanceState<A, E, R>,
|
|
||||||
select: (value: A) => Effect.Effect<B, E2, R2>,
|
|
||||||
) => Effect.flatMap(get(self), select)
|
|
||||||
|
|
||||||
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
|
||||||
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
|
|
||||||
|
|
||||||
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
|
||||||
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||||
|
import { File } from "@/file/service"
|
||||||
|
import { FileTime } from "@/file/time-service"
|
||||||
|
import { FileWatcher } from "@/file/watcher"
|
||||||
|
import { Format } from "@/format/service"
|
||||||
|
import { Permission } from "@/permission/service"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import { Vcs } from "@/project/vcs"
|
||||||
|
import { ProviderAuth } from "@/provider/auth-service"
|
||||||
|
import { Question } from "@/question/service"
|
||||||
|
import { Skill } from "@/skill/service"
|
||||||
|
import { Snapshot } from "@/snapshot/service"
|
||||||
|
import { Plugin } from "@/plugin"
|
||||||
|
import { InstanceContext } from "./instance-context"
|
||||||
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
|
||||||
|
export { InstanceContext } from "./instance-context"
|
||||||
|
|
||||||
|
export type InstanceServices =
|
||||||
|
| Question.Service
|
||||||
|
| Permission.Service
|
||||||
|
| ProviderAuth.Service
|
||||||
|
| FileWatcher.Service
|
||||||
|
| Vcs.Service
|
||||||
|
| FileTime.Service
|
||||||
|
| Format.Service
|
||||||
|
| File.Service
|
||||||
|
| Skill.Service
|
||||||
|
| Snapshot.Service
|
||||||
|
| Plugin.Service
|
||||||
|
|
||||||
|
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
||||||
|
// the full instance context (directory, worktree, project). We read from the
|
||||||
|
// legacy Instance ALS here, which is safe because lookup is only triggered via
|
||||||
|
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
|
||||||
|
// This should go away once the old Instance type is removed and lookup can load
|
||||||
|
// the full context directly.
|
||||||
|
function lookup(_key: string) {
|
||||||
|
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||||
|
return Layer.mergeAll(
|
||||||
|
Question.layer,
|
||||||
|
Permission.layer,
|
||||||
|
ProviderAuth.defaultLayer,
|
||||||
|
FileWatcher.layer,
|
||||||
|
Vcs.layer,
|
||||||
|
FileTime.layer,
|
||||||
|
Format.layer,
|
||||||
|
File.layer,
|
||||||
|
Skill.defaultLayer,
|
||||||
|
Snapshot.defaultLayer,
|
||||||
|
Plugin.layer,
|
||||||
|
).pipe(Layer.provide(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
|
||||||
|
"opencode/Instances",
|
||||||
|
) {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
Instances,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||||
|
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||||
|
return Instances.of(layerMap)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
||||||
|
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
|
||||||
import * as ServiceMap from "effect/ServiceMap"
|
|
||||||
|
|
||||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
|
||||||
|
|
||||||
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
|
||||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
|
||||||
|
|
||||||
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
|
|
||||||
rt ??= ManagedRuntime.make(layer, { memoMap })
|
|
||||||
return rt.runPromise(service.use(fn), options)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
|
import { Account } from "@/account/effect"
|
||||||
|
import { Auth } from "@/auth/effect"
|
||||||
|
import { Instances } from "@/effect/instances"
|
||||||
|
import type { InstanceServices } from "@/effect/instances"
|
||||||
|
import { Installation } from "@/installation"
|
||||||
|
import { Truncate } from "@/tool/truncate-effect"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
|
||||||
|
export const runtime = ManagedRuntime.make(
|
||||||
|
Layer.mergeAll(
|
||||||
|
Account.defaultLayer, //
|
||||||
|
Installation.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Instances.layer,
|
||||||
|
).pipe(Layer.provideMerge(Auth.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||||
|
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeRuntime() {
|
||||||
|
return runtime.dispose()
|
||||||
|
}
|
||||||
@@ -1,712 +1,40 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { File as S } from "./service"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { git } from "@/util/git"
|
|
||||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
|
||||||
import fs from "fs"
|
|
||||||
import fuzzysort from "fuzzysort"
|
|
||||||
import ignore from "ignore"
|
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { Protected } from "./protected"
|
|
||||||
import { Ripgrep } from "./ripgrep"
|
|
||||||
|
|
||||||
export namespace File {
|
export namespace File {
|
||||||
export const Info = z
|
export const Info = S.Info
|
||||||
.object({
|
export type Info = S.Info
|
||||||
path: z.string(),
|
|
||||||
added: z.number().int(),
|
|
||||||
removed: z.number().int(),
|
|
||||||
status: z.enum(["added", "deleted", "modified"]),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "File",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>
|
export const Node = S.Node
|
||||||
|
export type Node = S.Node
|
||||||
|
|
||||||
export const Node = z
|
export const Content = S.Content
|
||||||
.object({
|
export type Content = S.Content
|
||||||
name: z.string(),
|
|
||||||
path: z.string(),
|
|
||||||
absolute: z.string(),
|
|
||||||
type: z.enum(["file", "directory"]),
|
|
||||||
ignored: z.boolean(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FileNode",
|
|
||||||
})
|
|
||||||
export type Node = z.infer<typeof Node>
|
|
||||||
|
|
||||||
export const Content = z
|
export const Event = S.Event
|
||||||
.object({
|
|
||||||
type: z.enum(["text", "binary"]),
|
|
||||||
content: z.string(),
|
|
||||||
diff: z.string().optional(),
|
|
||||||
patch: z
|
|
||||||
.object({
|
|
||||||
oldFileName: z.string(),
|
|
||||||
newFileName: z.string(),
|
|
||||||
oldHeader: z.string().optional(),
|
|
||||||
newHeader: z.string().optional(),
|
|
||||||
hunks: z.array(
|
|
||||||
z.object({
|
|
||||||
oldStart: z.number(),
|
|
||||||
oldLines: z.number(),
|
|
||||||
newStart: z.number(),
|
|
||||||
newLines: z.number(),
|
|
||||||
lines: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
index: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
encoding: z.literal("base64").optional(),
|
|
||||||
mimeType: z.string().optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FileContent",
|
|
||||||
})
|
|
||||||
export type Content = z.infer<typeof Content>
|
|
||||||
|
|
||||||
export const Event = {
|
export type Interface = S.Interface
|
||||||
Edited: BusEvent.define(
|
|
||||||
"file.edited",
|
|
||||||
z.object({
|
|
||||||
file: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const log = Log.create({ service: "file" })
|
export const Service = S.Service
|
||||||
|
export const layer = S.layer
|
||||||
const binary = new Set([
|
|
||||||
"exe",
|
|
||||||
"dll",
|
|
||||||
"pdb",
|
|
||||||
"bin",
|
|
||||||
"so",
|
|
||||||
"dylib",
|
|
||||||
"o",
|
|
||||||
"a",
|
|
||||||
"lib",
|
|
||||||
"wav",
|
|
||||||
"mp3",
|
|
||||||
"ogg",
|
|
||||||
"oga",
|
|
||||||
"ogv",
|
|
||||||
"ogx",
|
|
||||||
"flac",
|
|
||||||
"aac",
|
|
||||||
"wma",
|
|
||||||
"m4a",
|
|
||||||
"weba",
|
|
||||||
"mp4",
|
|
||||||
"avi",
|
|
||||||
"mov",
|
|
||||||
"wmv",
|
|
||||||
"flv",
|
|
||||||
"webm",
|
|
||||||
"mkv",
|
|
||||||
"zip",
|
|
||||||
"tar",
|
|
||||||
"gz",
|
|
||||||
"gzip",
|
|
||||||
"bz",
|
|
||||||
"bz2",
|
|
||||||
"bzip",
|
|
||||||
"bzip2",
|
|
||||||
"7z",
|
|
||||||
"rar",
|
|
||||||
"xz",
|
|
||||||
"lz",
|
|
||||||
"z",
|
|
||||||
"pdf",
|
|
||||||
"doc",
|
|
||||||
"docx",
|
|
||||||
"ppt",
|
|
||||||
"pptx",
|
|
||||||
"xls",
|
|
||||||
"xlsx",
|
|
||||||
"dmg",
|
|
||||||
"iso",
|
|
||||||
"img",
|
|
||||||
"vmdk",
|
|
||||||
"ttf",
|
|
||||||
"otf",
|
|
||||||
"woff",
|
|
||||||
"woff2",
|
|
||||||
"eot",
|
|
||||||
"sqlite",
|
|
||||||
"db",
|
|
||||||
"mdb",
|
|
||||||
"apk",
|
|
||||||
"ipa",
|
|
||||||
"aab",
|
|
||||||
"xapk",
|
|
||||||
"app",
|
|
||||||
"pkg",
|
|
||||||
"deb",
|
|
||||||
"rpm",
|
|
||||||
"snap",
|
|
||||||
"flatpak",
|
|
||||||
"appimage",
|
|
||||||
"msi",
|
|
||||||
"msp",
|
|
||||||
"jar",
|
|
||||||
"war",
|
|
||||||
"ear",
|
|
||||||
"class",
|
|
||||||
"kotlin_module",
|
|
||||||
"dex",
|
|
||||||
"vdex",
|
|
||||||
"odex",
|
|
||||||
"oat",
|
|
||||||
"art",
|
|
||||||
"wasm",
|
|
||||||
"wat",
|
|
||||||
"bc",
|
|
||||||
"ll",
|
|
||||||
"s",
|
|
||||||
"ko",
|
|
||||||
"sys",
|
|
||||||
"drv",
|
|
||||||
"efi",
|
|
||||||
"rom",
|
|
||||||
"com",
|
|
||||||
])
|
|
||||||
|
|
||||||
const image = new Set([
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gif",
|
|
||||||
"bmp",
|
|
||||||
"webp",
|
|
||||||
"ico",
|
|
||||||
"tif",
|
|
||||||
"tiff",
|
|
||||||
"svg",
|
|
||||||
"svgz",
|
|
||||||
"avif",
|
|
||||||
"apng",
|
|
||||||
"jxl",
|
|
||||||
"heic",
|
|
||||||
"heif",
|
|
||||||
"raw",
|
|
||||||
"cr2",
|
|
||||||
"nef",
|
|
||||||
"arw",
|
|
||||||
"dng",
|
|
||||||
"orf",
|
|
||||||
"raf",
|
|
||||||
"pef",
|
|
||||||
"x3f",
|
|
||||||
])
|
|
||||||
|
|
||||||
const text = new Set([
|
|
||||||
"ts",
|
|
||||||
"tsx",
|
|
||||||
"mts",
|
|
||||||
"cts",
|
|
||||||
"mtsx",
|
|
||||||
"ctsx",
|
|
||||||
"js",
|
|
||||||
"jsx",
|
|
||||||
"mjs",
|
|
||||||
"cjs",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"fish",
|
|
||||||
"ps1",
|
|
||||||
"psm1",
|
|
||||||
"cmd",
|
|
||||||
"bat",
|
|
||||||
"json",
|
|
||||||
"jsonc",
|
|
||||||
"json5",
|
|
||||||
"yaml",
|
|
||||||
"yml",
|
|
||||||
"toml",
|
|
||||||
"md",
|
|
||||||
"mdx",
|
|
||||||
"txt",
|
|
||||||
"xml",
|
|
||||||
"html",
|
|
||||||
"htm",
|
|
||||||
"css",
|
|
||||||
"scss",
|
|
||||||
"sass",
|
|
||||||
"less",
|
|
||||||
"graphql",
|
|
||||||
"gql",
|
|
||||||
"sql",
|
|
||||||
"ini",
|
|
||||||
"cfg",
|
|
||||||
"conf",
|
|
||||||
"env",
|
|
||||||
])
|
|
||||||
|
|
||||||
const textName = new Set([
|
|
||||||
"dockerfile",
|
|
||||||
"makefile",
|
|
||||||
".gitignore",
|
|
||||||
".gitattributes",
|
|
||||||
".editorconfig",
|
|
||||||
".npmrc",
|
|
||||||
".nvmrc",
|
|
||||||
".prettierrc",
|
|
||||||
".eslintrc",
|
|
||||||
])
|
|
||||||
|
|
||||||
const mime: Record<string, string> = {
|
|
||||||
png: "image/png",
|
|
||||||
jpg: "image/jpeg",
|
|
||||||
jpeg: "image/jpeg",
|
|
||||||
gif: "image/gif",
|
|
||||||
bmp: "image/bmp",
|
|
||||||
webp: "image/webp",
|
|
||||||
ico: "image/x-icon",
|
|
||||||
tif: "image/tiff",
|
|
||||||
tiff: "image/tiff",
|
|
||||||
svg: "image/svg+xml",
|
|
||||||
svgz: "image/svg+xml",
|
|
||||||
avif: "image/avif",
|
|
||||||
apng: "image/apng",
|
|
||||||
jxl: "image/jxl",
|
|
||||||
heic: "image/heic",
|
|
||||||
heif: "image/heif",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Entry = { files: string[]; dirs: string[] }
|
|
||||||
|
|
||||||
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
|
||||||
const name = (file: string) => path.basename(file).toLowerCase()
|
|
||||||
const isImageByExtension = (file: string) => image.has(ext(file))
|
|
||||||
const isTextByExtension = (file: string) => text.has(ext(file))
|
|
||||||
const isTextByName = (file: string) => textName.has(name(file))
|
|
||||||
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
|
||||||
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
|
||||||
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
|
||||||
|
|
||||||
function shouldEncode(mimeType: string) {
|
|
||||||
const type = mimeType.toLowerCase()
|
|
||||||
log.debug("shouldEncode", { type })
|
|
||||||
if (!type) return false
|
|
||||||
if (type.startsWith("text/")) return false
|
|
||||||
if (type.includes("charset=")) return false
|
|
||||||
const top = type.split("/", 2)[0]
|
|
||||||
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hidden = (item: string) => {
|
|
||||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
|
||||||
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
|
||||||
if (prefer) return items
|
|
||||||
const visible: string[] = []
|
|
||||||
const hiddenItems: string[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (hidden(item)) hiddenItems.push(item)
|
|
||||||
else visible.push(item)
|
|
||||||
}
|
|
||||||
return [...visible, ...hiddenItems]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
cache: Entry
|
|
||||||
fiber: Fiber.Fiber<void> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly status: () => Effect.Effect<File.Info[]>
|
|
||||||
readonly read: (file: string) => Effect.Effect<File.Content>
|
|
||||||
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
|
|
||||||
readonly search: (input: {
|
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
dirs?: boolean
|
|
||||||
type?: "file" | "directory"
|
|
||||||
}) => Effect.Effect<string[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("File.state")(() =>
|
|
||||||
Effect.succeed({
|
|
||||||
cache: { files: [], dirs: [] } as Entry,
|
|
||||||
fiber: undefined as Fiber.Fiber<void> | undefined,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const scan = Effect.fn("File.scan")(function* () {
|
|
||||||
if (Instance.directory === path.parse(Instance.directory).root) return
|
|
||||||
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
|
||||||
const next: Entry = { files: [], dirs: [] }
|
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
if (isGlobalHome) {
|
|
||||||
const dirs = new Set<string>()
|
|
||||||
const protectedNames = Protected.names()
|
|
||||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
|
||||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
|
||||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
|
||||||
const top = await fs.promises
|
|
||||||
.readdir(Instance.directory, { withFileTypes: true })
|
|
||||||
.catch(() => [] as fs.Dirent[])
|
|
||||||
|
|
||||||
for (const entry of top) {
|
|
||||||
if (!entry.isDirectory()) continue
|
|
||||||
if (shouldIgnoreName(entry.name)) continue
|
|
||||||
dirs.add(entry.name + "/")
|
|
||||||
|
|
||||||
const base = path.join(Instance.directory, entry.name)
|
|
||||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
|
||||||
for (const child of children) {
|
|
||||||
if (!child.isDirectory()) continue
|
|
||||||
if (shouldIgnoreNested(child.name)) continue
|
|
||||||
dirs.add(entry.name + "/" + child.name + "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next.dirs = Array.from(dirs).toSorted()
|
|
||||||
} else {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
|
|
||||||
next.files.push(file)
|
|
||||||
let current = file
|
|
||||||
while (true) {
|
|
||||||
const dir = path.dirname(current)
|
|
||||||
if (dir === ".") break
|
|
||||||
if (dir === current) break
|
|
||||||
current = dir
|
|
||||||
if (seen.has(dir)) continue
|
|
||||||
seen.add(dir)
|
|
||||||
next.dirs.push(dir + "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
s.cache = next
|
|
||||||
})
|
|
||||||
|
|
||||||
const scope = yield* Scope.Scope
|
|
||||||
|
|
||||||
const ensure = Effect.fn("File.ensure")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
if (!s.fiber)
|
|
||||||
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* () {
|
|
||||||
yield* ensure()
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("File.status")(function* () {
|
|
||||||
if (Instance.project.vcs !== "git") return []
|
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
|
||||||
const diffOutput = (
|
|
||||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
|
||||||
cwd: Instance.directory,
|
|
||||||
})
|
|
||||||
).text()
|
|
||||||
|
|
||||||
const changed: File.Info[] = []
|
|
||||||
|
|
||||||
if (diffOutput.trim()) {
|
|
||||||
for (const line of diffOutput.trim().split("\n")) {
|
|
||||||
const [added, removed, file] = line.split("\t")
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: added === "-" ? 0 : parseInt(added, 10),
|
|
||||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
|
||||||
status: "modified",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const untrackedOutput = (
|
|
||||||
await git(
|
|
||||||
[
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
"ls-files",
|
|
||||||
"--others",
|
|
||||||
"--exclude-standard",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: Instance.directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).text()
|
|
||||||
|
|
||||||
if (untrackedOutput.trim()) {
|
|
||||||
for (const file of untrackedOutput.trim().split("\n")) {
|
|
||||||
try {
|
|
||||||
const content = await Filesystem.readText(path.join(Instance.directory, file))
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: content.split("\n").length,
|
|
||||||
removed: 0,
|
|
||||||
status: "added",
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedOutput = (
|
|
||||||
await git(
|
|
||||||
[
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
"diff",
|
|
||||||
"--name-only",
|
|
||||||
"--diff-filter=D",
|
|
||||||
"HEAD",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: Instance.directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).text()
|
|
||||||
|
|
||||||
if (deletedOutput.trim()) {
|
|
||||||
for (const file of deletedOutput.trim().split("\n")) {
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: 0,
|
|
||||||
removed: 0,
|
|
||||||
status: "deleted",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed.map((item) => {
|
|
||||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
path: path.relative(Instance.directory, full),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const read = Effect.fn("File.read")(function* (file: string) {
|
|
||||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
|
||||||
using _ = log.time("read", { file })
|
|
||||||
const full = path.join(Instance.directory, file)
|
|
||||||
|
|
||||||
if (!Instance.containsPath(full)) {
|
|
||||||
throw new Error("Access denied: path escapes project directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isImageByExtension(file)) {
|
|
||||||
if (await Filesystem.exists(full)) {
|
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
content: buffer.toString("base64"),
|
|
||||||
mimeType: getImageMimeType(file),
|
|
||||||
encoding: "base64",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { type: "text", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
|
||||||
|
|
||||||
if (isBinaryByExtension(file) && !knownText) {
|
|
||||||
return { type: "binary", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await Filesystem.exists(full))) {
|
|
||||||
return { type: "text", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = Filesystem.mimeType(full)
|
|
||||||
const encode = knownText ? false : shouldEncode(mimeType)
|
|
||||||
|
|
||||||
if (encode && !isImage(mimeType)) {
|
|
||||||
return { type: "binary", content: "", mimeType }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encode) {
|
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
content: buffer.toString("base64"),
|
|
||||||
mimeType,
|
|
||||||
encoding: "base64",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
|
||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
|
||||||
let diff = (
|
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
|
||||||
).text()
|
|
||||||
if (!diff.trim()) {
|
|
||||||
diff = (
|
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
|
||||||
cwd: Instance.directory,
|
|
||||||
})
|
|
||||||
).text()
|
|
||||||
}
|
|
||||||
if (diff.trim()) {
|
|
||||||
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
|
||||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
|
||||||
context: Infinity,
|
|
||||||
ignoreWhitespace: true,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
content,
|
|
||||||
patch,
|
|
||||||
diff: formatPatch(patch),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: "text", content }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
|
||||||
return yield* Effect.promise(async () => {
|
|
||||||
const exclude = [".git", ".DS_Store"]
|
|
||||||
let ignored = (_: string) => false
|
|
||||||
if (Instance.project.vcs === "git") {
|
|
||||||
const ig = ignore()
|
|
||||||
const gitignore = path.join(Instance.project.worktree, ".gitignore")
|
|
||||||
if (await Filesystem.exists(gitignore)) {
|
|
||||||
ig.add(await Filesystem.readText(gitignore))
|
|
||||||
}
|
|
||||||
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
|
||||||
if (await Filesystem.exists(ignoreFile)) {
|
|
||||||
ig.add(await Filesystem.readText(ignoreFile))
|
|
||||||
}
|
|
||||||
ignored = ig.ignores.bind(ig)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
|
||||||
if (!Instance.containsPath(resolved)) {
|
|
||||||
throw new Error("Access denied: path escapes project directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes: File.Node[] = []
|
|
||||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
|
||||||
if (exclude.includes(entry.name)) continue
|
|
||||||
const absolute = path.join(resolved, entry.name)
|
|
||||||
const file = path.relative(Instance.directory, absolute)
|
|
||||||
const type = entry.isDirectory() ? "directory" : "file"
|
|
||||||
nodes.push({
|
|
||||||
name: entry.name,
|
|
||||||
path: file,
|
|
||||||
absolute,
|
|
||||||
type,
|
|
||||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes.sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const search = Effect.fn("File.search")(function* (input: {
|
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
dirs?: boolean
|
|
||||||
type?: "file" | "directory"
|
|
||||||
}) {
|
|
||||||
yield* ensure()
|
|
||||||
const { cache } = yield* InstanceState.get(state)
|
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
|
||||||
const query = input.query.trim()
|
|
||||||
const limit = input.limit ?? 100
|
|
||||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
|
||||||
log.info("search", { query, kind })
|
|
||||||
|
|
||||||
const result = cache
|
|
||||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
if (kind === "file") return result.files.slice(0, limit)
|
|
||||||
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const items =
|
|
||||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
|
||||||
|
|
||||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
|
||||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
|
||||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
|
||||||
|
|
||||||
log.info("search", { query, kind, results: output.length })
|
|
||||||
return output
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
log.info("init")
|
|
||||||
return Service.of({ init, status, read, list, search })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
return runPromise((svc) => svc.init())
|
return runPromiseInstance(S.Service.use((svc) => svc.init()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromise((svc) => svc.status())
|
return runPromiseInstance(S.Service.use((svc) => svc.status()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function read(file: string): Promise<Content> {
|
export async function read(file: string): Promise<Content> {
|
||||||
return runPromise((svc) => svc.read(file))
|
return runPromiseInstance(S.Service.use((svc) => svc.read(file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(dir?: string) {
|
export async function list(dir?: string) {
|
||||||
return runPromise((svc) => svc.list(dir))
|
return runPromiseInstance(S.Service.use((svc) => svc.list(dir)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||||
return runPromise((svc) => svc.search(input))
|
return runPromiseInstance(S.Service.use((svc) => svc.search(input)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import { git } from "@/util/git"
|
||||||
|
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||||
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
|
import fs from "fs"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
|
import ignore from "ignore"
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { Protected } from "./protected"
|
||||||
|
import { Ripgrep } from "./ripgrep"
|
||||||
|
|
||||||
|
export namespace File {
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
path: z.string(),
|
||||||
|
added: z.number().int(),
|
||||||
|
removed: z.number().int(),
|
||||||
|
status: z.enum(["added", "deleted", "modified"]),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "File",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const Node = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
absolute: z.string(),
|
||||||
|
type: z.enum(["file", "directory"]),
|
||||||
|
ignored: z.boolean(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FileNode",
|
||||||
|
})
|
||||||
|
export type Node = z.infer<typeof Node>
|
||||||
|
|
||||||
|
export const Content = z
|
||||||
|
.object({
|
||||||
|
type: z.enum(["text", "binary"]),
|
||||||
|
content: z.string(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
patch: z
|
||||||
|
.object({
|
||||||
|
oldFileName: z.string(),
|
||||||
|
newFileName: z.string(),
|
||||||
|
oldHeader: z.string().optional(),
|
||||||
|
newHeader: z.string().optional(),
|
||||||
|
hunks: z.array(
|
||||||
|
z.object({
|
||||||
|
oldStart: z.number(),
|
||||||
|
oldLines: z.number(),
|
||||||
|
newStart: z.number(),
|
||||||
|
newLines: z.number(),
|
||||||
|
lines: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
index: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
encoding: z.literal("base64").optional(),
|
||||||
|
mimeType: z.string().optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FileContent",
|
||||||
|
})
|
||||||
|
export type Content = z.infer<typeof Content>
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Edited: BusEvent.define(
|
||||||
|
"file.edited",
|
||||||
|
z.object({
|
||||||
|
file: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = Log.create({ service: "file" })
|
||||||
|
|
||||||
|
const binary = new Set([
|
||||||
|
"exe",
|
||||||
|
"dll",
|
||||||
|
"pdb",
|
||||||
|
"bin",
|
||||||
|
"so",
|
||||||
|
"dylib",
|
||||||
|
"o",
|
||||||
|
"a",
|
||||||
|
"lib",
|
||||||
|
"wav",
|
||||||
|
"mp3",
|
||||||
|
"ogg",
|
||||||
|
"oga",
|
||||||
|
"ogv",
|
||||||
|
"ogx",
|
||||||
|
"flac",
|
||||||
|
"aac",
|
||||||
|
"wma",
|
||||||
|
"m4a",
|
||||||
|
"weba",
|
||||||
|
"mp4",
|
||||||
|
"avi",
|
||||||
|
"mov",
|
||||||
|
"wmv",
|
||||||
|
"flv",
|
||||||
|
"webm",
|
||||||
|
"mkv",
|
||||||
|
"zip",
|
||||||
|
"tar",
|
||||||
|
"gz",
|
||||||
|
"gzip",
|
||||||
|
"bz",
|
||||||
|
"bz2",
|
||||||
|
"bzip",
|
||||||
|
"bzip2",
|
||||||
|
"7z",
|
||||||
|
"rar",
|
||||||
|
"xz",
|
||||||
|
"lz",
|
||||||
|
"z",
|
||||||
|
"pdf",
|
||||||
|
"doc",
|
||||||
|
"docx",
|
||||||
|
"ppt",
|
||||||
|
"pptx",
|
||||||
|
"xls",
|
||||||
|
"xlsx",
|
||||||
|
"dmg",
|
||||||
|
"iso",
|
||||||
|
"img",
|
||||||
|
"vmdk",
|
||||||
|
"ttf",
|
||||||
|
"otf",
|
||||||
|
"woff",
|
||||||
|
"woff2",
|
||||||
|
"eot",
|
||||||
|
"sqlite",
|
||||||
|
"db",
|
||||||
|
"mdb",
|
||||||
|
"apk",
|
||||||
|
"ipa",
|
||||||
|
"aab",
|
||||||
|
"xapk",
|
||||||
|
"app",
|
||||||
|
"pkg",
|
||||||
|
"deb",
|
||||||
|
"rpm",
|
||||||
|
"snap",
|
||||||
|
"flatpak",
|
||||||
|
"appimage",
|
||||||
|
"msi",
|
||||||
|
"msp",
|
||||||
|
"jar",
|
||||||
|
"war",
|
||||||
|
"ear",
|
||||||
|
"class",
|
||||||
|
"kotlin_module",
|
||||||
|
"dex",
|
||||||
|
"vdex",
|
||||||
|
"odex",
|
||||||
|
"oat",
|
||||||
|
"art",
|
||||||
|
"wasm",
|
||||||
|
"wat",
|
||||||
|
"bc",
|
||||||
|
"ll",
|
||||||
|
"s",
|
||||||
|
"ko",
|
||||||
|
"sys",
|
||||||
|
"drv",
|
||||||
|
"efi",
|
||||||
|
"rom",
|
||||||
|
"com",
|
||||||
|
"cmd",
|
||||||
|
"ps1",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
])
|
||||||
|
|
||||||
|
const image = new Set([
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"bmp",
|
||||||
|
"webp",
|
||||||
|
"ico",
|
||||||
|
"tif",
|
||||||
|
"tiff",
|
||||||
|
"svg",
|
||||||
|
"svgz",
|
||||||
|
"avif",
|
||||||
|
"apng",
|
||||||
|
"jxl",
|
||||||
|
"heic",
|
||||||
|
"heif",
|
||||||
|
"raw",
|
||||||
|
"cr2",
|
||||||
|
"nef",
|
||||||
|
"arw",
|
||||||
|
"dng",
|
||||||
|
"orf",
|
||||||
|
"raf",
|
||||||
|
"pef",
|
||||||
|
"x3f",
|
||||||
|
])
|
||||||
|
|
||||||
|
const text = new Set([
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"mts",
|
||||||
|
"cts",
|
||||||
|
"mtsx",
|
||||||
|
"ctsx",
|
||||||
|
"js",
|
||||||
|
"jsx",
|
||||||
|
"mjs",
|
||||||
|
"cjs",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
"ps1",
|
||||||
|
"psm1",
|
||||||
|
"cmd",
|
||||||
|
"bat",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"json5",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"md",
|
||||||
|
"mdx",
|
||||||
|
"txt",
|
||||||
|
"xml",
|
||||||
|
"html",
|
||||||
|
"htm",
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"sass",
|
||||||
|
"less",
|
||||||
|
"graphql",
|
||||||
|
"gql",
|
||||||
|
"sql",
|
||||||
|
"ini",
|
||||||
|
"cfg",
|
||||||
|
"conf",
|
||||||
|
"env",
|
||||||
|
])
|
||||||
|
|
||||||
|
const textName = new Set([
|
||||||
|
"dockerfile",
|
||||||
|
"makefile",
|
||||||
|
".gitignore",
|
||||||
|
".gitattributes",
|
||||||
|
".editorconfig",
|
||||||
|
".npmrc",
|
||||||
|
".nvmrc",
|
||||||
|
".prettierrc",
|
||||||
|
".eslintrc",
|
||||||
|
])
|
||||||
|
|
||||||
|
const mime: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
webp: "image/webp",
|
||||||
|
ico: "image/x-icon",
|
||||||
|
tif: "image/tiff",
|
||||||
|
tiff: "image/tiff",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
svgz: "image/svg+xml",
|
||||||
|
avif: "image/avif",
|
||||||
|
apng: "image/apng",
|
||||||
|
jxl: "image/jxl",
|
||||||
|
heic: "image/heic",
|
||||||
|
heif: "image/heif",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = { files: string[]; dirs: string[] }
|
||||||
|
|
||||||
|
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
||||||
|
const name = (file: string) => path.basename(file).toLowerCase()
|
||||||
|
const isImageByExtension = (file: string) => image.has(ext(file))
|
||||||
|
const isTextByExtension = (file: string) => text.has(ext(file))
|
||||||
|
const isTextByName = (file: string) => textName.has(name(file))
|
||||||
|
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
||||||
|
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
||||||
|
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
||||||
|
|
||||||
|
function shouldEncode(mimeType: string) {
|
||||||
|
const type = mimeType.toLowerCase()
|
||||||
|
log.info("shouldEncode", { type })
|
||||||
|
if (!type) return false
|
||||||
|
if (type.startsWith("text/")) return false
|
||||||
|
if (type.includes("charset=")) return false
|
||||||
|
const top = type.split("/", 2)[0]
|
||||||
|
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = (item: string) => {
|
||||||
|
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||||
|
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
||||||
|
if (prefer) return items
|
||||||
|
const visible: string[] = []
|
||||||
|
const hiddenItems: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (hidden(item)) hiddenItems.push(item)
|
||||||
|
else visible.push(item)
|
||||||
|
}
|
||||||
|
return [...visible, ...hiddenItems]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly status: () => Effect.Effect<File.Info[]>
|
||||||
|
readonly read: (file: string) => Effect.Effect<File.Content>
|
||||||
|
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
|
||||||
|
readonly search: (input: {
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
dirs?: boolean
|
||||||
|
type?: "file" | "directory"
|
||||||
|
}) => Effect.Effect<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const instance = yield* InstanceContext
|
||||||
|
let cache: Entry = { files: [], dirs: [] }
|
||||||
|
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
||||||
|
|
||||||
|
const scan = Effect.fn("File.scan")(function* () {
|
||||||
|
if (instance.directory === path.parse(instance.directory).root) return
|
||||||
|
const next: Entry = { files: [], dirs: [] }
|
||||||
|
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
if (isGlobalHome) {
|
||||||
|
const dirs = new Set<string>()
|
||||||
|
const protectedNames = Protected.names()
|
||||||
|
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||||
|
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||||
|
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||||
|
const top = await fs.promises
|
||||||
|
.readdir(instance.directory, { withFileTypes: true })
|
||||||
|
.catch(() => [] as fs.Dirent[])
|
||||||
|
|
||||||
|
for (const entry of top) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
if (shouldIgnoreName(entry.name)) continue
|
||||||
|
dirs.add(entry.name + "/")
|
||||||
|
|
||||||
|
const base = path.join(instance.directory, entry.name)
|
||||||
|
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||||
|
for (const child of children) {
|
||||||
|
if (!child.isDirectory()) continue
|
||||||
|
if (shouldIgnoreNested(child.name)) continue
|
||||||
|
dirs.add(entry.name + "/" + child.name + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.dirs = Array.from(dirs).toSorted()
|
||||||
|
} else {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
||||||
|
next.files.push(file)
|
||||||
|
let current = file
|
||||||
|
while (true) {
|
||||||
|
const dir = path.dirname(current)
|
||||||
|
if (dir === ".") break
|
||||||
|
if (dir === current) break
|
||||||
|
current = dir
|
||||||
|
if (seen.has(dir)) continue
|
||||||
|
seen.add(dir)
|
||||||
|
next.dirs.push(dir + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cache = next
|
||||||
|
})
|
||||||
|
|
||||||
|
const getFiles = () => cache
|
||||||
|
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
let fiber: Fiber.Fiber<void> | undefined
|
||||||
|
|
||||||
|
const init = Effect.fn("File.init")(function* () {
|
||||||
|
if (!fiber) {
|
||||||
|
fiber = yield* scan().pipe(
|
||||||
|
Effect.catchCause(() => Effect.void),
|
||||||
|
Effect.forkIn(scope),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
yield* Fiber.join(fiber)
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = Effect.fn("File.status")(function* () {
|
||||||
|
if (instance.project.vcs !== "git") return []
|
||||||
|
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const diffOutput = (
|
||||||
|
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||||
|
cwd: instance.directory,
|
||||||
|
})
|
||||||
|
).text()
|
||||||
|
|
||||||
|
const changed: File.Info[] = []
|
||||||
|
|
||||||
|
if (diffOutput.trim()) {
|
||||||
|
for (const line of diffOutput.trim().split("\n")) {
|
||||||
|
const [added, removed, file] = line.split("\t")
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: added === "-" ? 0 : parseInt(added, 10),
|
||||||
|
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||||
|
status: "modified",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const untrackedOutput = (
|
||||||
|
await git(
|
||||||
|
[
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
"ls-files",
|
||||||
|
"--others",
|
||||||
|
"--exclude-standard",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: instance.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).text()
|
||||||
|
|
||||||
|
if (untrackedOutput.trim()) {
|
||||||
|
for (const file of untrackedOutput.trim().split("\n")) {
|
||||||
|
try {
|
||||||
|
const content = await Filesystem.readText(path.join(instance.directory, file))
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: content.split("\n").length,
|
||||||
|
removed: 0,
|
||||||
|
status: "added",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedOutput = (
|
||||||
|
await git(
|
||||||
|
[
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
"diff",
|
||||||
|
"--name-only",
|
||||||
|
"--diff-filter=D",
|
||||||
|
"HEAD",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: instance.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).text()
|
||||||
|
|
||||||
|
if (deletedOutput.trim()) {
|
||||||
|
for (const file of deletedOutput.trim().split("\n")) {
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: 0,
|
||||||
|
removed: 0,
|
||||||
|
status: "deleted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed.map((item) => {
|
||||||
|
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
path: path.relative(instance.directory, full),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const read = Effect.fn("File.read")(function* (file: string) {
|
||||||
|
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||||
|
using _ = log.time("read", { file })
|
||||||
|
const full = path.join(instance.directory, file)
|
||||||
|
|
||||||
|
if (!Instance.containsPath(full)) {
|
||||||
|
throw new Error("Access denied: path escapes project directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isImageByExtension(file)) {
|
||||||
|
if (await Filesystem.exists(full)) {
|
||||||
|
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
content: buffer.toString("base64"),
|
||||||
|
mimeType: getImageMimeType(file),
|
||||||
|
encoding: "base64",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: "text", content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||||
|
|
||||||
|
if (isBinaryByExtension(file) && !knownText) {
|
||||||
|
return { type: "binary", content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await Filesystem.exists(full))) {
|
||||||
|
return { type: "text", content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = Filesystem.mimeType(full)
|
||||||
|
const encode = knownText ? false : shouldEncode(mimeType)
|
||||||
|
|
||||||
|
if (encode && !isImage(mimeType)) {
|
||||||
|
return { type: "binary", content: "", mimeType }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encode) {
|
||||||
|
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
content: buffer.toString("base64"),
|
||||||
|
mimeType,
|
||||||
|
encoding: "base64",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||||
|
|
||||||
|
if (instance.project.vcs === "git") {
|
||||||
|
let diff = (
|
||||||
|
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
|
||||||
|
).text()
|
||||||
|
if (!diff.trim()) {
|
||||||
|
diff = (
|
||||||
|
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||||
|
cwd: instance.directory,
|
||||||
|
})
|
||||||
|
).text()
|
||||||
|
}
|
||||||
|
if (diff.trim()) {
|
||||||
|
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
|
||||||
|
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||||
|
context: Infinity,
|
||||||
|
ignoreWhitespace: true,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
content,
|
||||||
|
patch,
|
||||||
|
diff: formatPatch(patch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "text", content }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const exclude = [".git", ".DS_Store"]
|
||||||
|
let ignored = (_: string) => false
|
||||||
|
if (instance.project.vcs === "git") {
|
||||||
|
const ig = ignore()
|
||||||
|
const gitignore = path.join(instance.project.worktree, ".gitignore")
|
||||||
|
if (await Filesystem.exists(gitignore)) {
|
||||||
|
ig.add(await Filesystem.readText(gitignore))
|
||||||
|
}
|
||||||
|
const ignoreFile = path.join(instance.project.worktree, ".ignore")
|
||||||
|
if (await Filesystem.exists(ignoreFile)) {
|
||||||
|
ig.add(await Filesystem.readText(ignoreFile))
|
||||||
|
}
|
||||||
|
ignored = ig.ignores.bind(ig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
|
||||||
|
if (!Instance.containsPath(resolved)) {
|
||||||
|
throw new Error("Access denied: path escapes project directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: File.Node[] = []
|
||||||
|
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
||||||
|
if (exclude.includes(entry.name)) continue
|
||||||
|
const absolute = path.join(resolved, entry.name)
|
||||||
|
const file = path.relative(instance.directory, absolute)
|
||||||
|
const type = entry.isDirectory() ? "directory" : "file"
|
||||||
|
nodes.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: file,
|
||||||
|
absolute,
|
||||||
|
type,
|
||||||
|
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const search = Effect.fn("File.search")(function* (input: {
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
dirs?: boolean
|
||||||
|
type?: "file" | "directory"
|
||||||
|
}) {
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const query = input.query.trim()
|
||||||
|
const limit = input.limit ?? 100
|
||||||
|
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||||
|
log.info("search", { query, kind })
|
||||||
|
|
||||||
|
const result = getFiles()
|
||||||
|
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
if (kind === "file") return result.files.slice(0, limit)
|
||||||
|
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items =
|
||||||
|
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||||
|
|
||||||
|
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||||
|
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||||
|
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||||
|
|
||||||
|
log.info("search", { query, kind, results: output.length })
|
||||||
|
return output
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("init")
|
||||||
|
return Service.of({ init, status, read, list, search })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.fresh)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import type { SessionID } from "@/session/schema"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
|
export namespace FileTime {
|
||||||
|
const log = Log.create({ service: "file.time" })
|
||||||
|
|
||||||
|
export type Stamp = {
|
||||||
|
readonly read: Date
|
||||||
|
readonly mtime: number | undefined
|
||||||
|
readonly ctime: number | undefined
|
||||||
|
readonly size: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||||
|
const stat = Filesystem.stat(file)
|
||||||
|
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||||
|
return {
|
||||||
|
read: yield* DateTime.nowAsDate,
|
||||||
|
mtime: stat?.mtime?.getTime(),
|
||||||
|
ctime: stat?.ctime?.getTime(),
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||||
|
const value = reads.get(sessionID)
|
||||||
|
if (value) return value
|
||||||
|
|
||||||
|
const next = new Map<string, Stamp>()
|
||||||
|
reads.set(sessionID, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||||
|
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||||
|
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||||
|
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||||
|
const reads = new Map<SessionID, Map<string, Stamp>>()
|
||||||
|
const locks = new Map<string, Semaphore.Semaphore>()
|
||||||
|
|
||||||
|
const getLock = (filepath: string) => {
|
||||||
|
const lock = locks.get(filepath)
|
||||||
|
if (lock) return lock
|
||||||
|
|
||||||
|
const next = Semaphore.makeUnsafe(1)
|
||||||
|
locks.set(filepath, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||||
|
log.info("read", { sessionID, file })
|
||||||
|
session(reads, sessionID).set(file, yield* stamp(file))
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||||
|
return reads.get(sessionID)?.get(file)?.read
|
||||||
|
})
|
||||||
|
|
||||||
|
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||||
|
if (disableCheck) return
|
||||||
|
|
||||||
|
const time = reads.get(sessionID)?.get(filepath)
|
||||||
|
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||||
|
|
||||||
|
const next = yield* stamp(filepath)
|
||||||
|
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||||
|
if (!changed) return
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||||
|
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ read, get, assert, withLock })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.orDie, Layer.fresh)
|
||||||
|
}
|
||||||
@@ -1,128 +1,28 @@
|
|||||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { FileTime as S } from "./time-service"
|
||||||
import { Log } from "../util/log"
|
|
||||||
|
|
||||||
export namespace FileTime {
|
export namespace FileTime {
|
||||||
const log = Log.create({ service: "file.time" })
|
export type Stamp = S.Stamp
|
||||||
|
|
||||||
export type Stamp = {
|
export type Interface = S.Interface
|
||||||
readonly read: Date
|
|
||||||
readonly mtime: number | undefined
|
|
||||||
readonly ctime: number | undefined
|
|
||||||
readonly size: number | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
export const Service = S.Service
|
||||||
const stat = Filesystem.stat(file)
|
export const layer = S.layer
|
||||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
|
||||||
return {
|
|
||||||
read: yield* DateTime.nowAsDate,
|
|
||||||
mtime: stat?.mtime?.getTime(),
|
|
||||||
ctime: stat?.ctime?.getTime(),
|
|
||||||
size,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
|
||||||
const value = reads.get(sessionID)
|
|
||||||
if (value) return value
|
|
||||||
|
|
||||||
const next = new Map<string, Stamp>()
|
|
||||||
reads.set(sessionID, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
reads: Map<SessionID, Map<string, Stamp>>
|
|
||||||
locks: Map<string, Semaphore.Semaphore>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
|
||||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
|
||||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
|
||||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("FileTime.state")(() =>
|
|
||||||
Effect.succeed({
|
|
||||||
reads: new Map<SessionID, Map<string, Stamp>>(),
|
|
||||||
locks: new Map<string, Semaphore.Semaphore>(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
|
|
||||||
const locks = (yield* InstanceState.get(state)).locks
|
|
||||||
const lock = locks.get(filepath)
|
|
||||||
if (lock) return lock
|
|
||||||
|
|
||||||
const next = Semaphore.makeUnsafe(1)
|
|
||||||
locks.set(filepath, next)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
|
||||||
const reads = (yield* InstanceState.get(state)).reads
|
|
||||||
log.info("read", { sessionID, file })
|
|
||||||
session(reads, sessionID).set(file, yield* stamp(file))
|
|
||||||
})
|
|
||||||
|
|
||||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
|
||||||
const reads = (yield* InstanceState.get(state)).reads
|
|
||||||
return reads.get(sessionID)?.get(file)?.read
|
|
||||||
})
|
|
||||||
|
|
||||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
|
||||||
if (disableCheck) return
|
|
||||||
|
|
||||||
const reads = (yield* InstanceState.get(state)).reads
|
|
||||||
const time = reads.get(sessionID)?.get(filepath)
|
|
||||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
|
||||||
|
|
||||||
const next = yield* stamp(filepath)
|
|
||||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
|
||||||
if (!changed) return
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
|
||||||
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ read, get, assert, withLock })
|
|
||||||
}),
|
|
||||||
).pipe(Layer.orDie)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export function read(sessionID: SessionID, file: string) {
|
export function read(sessionID: SessionID, file: string) {
|
||||||
return runPromise((s) => s.read(sessionID, file))
|
return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get(sessionID: SessionID, file: string) {
|
export function get(sessionID: SessionID, file: string) {
|
||||||
return runPromise((s) => s.get(sessionID, file))
|
return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assert(sessionID: SessionID, filepath: string) {
|
export async function assert(sessionID: SessionID, filepath: string) {
|
||||||
return runPromise((s) => s.assert(sessionID, filepath))
|
return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||||
return runPromise((s) => s.withLock(filepath, fn))
|
return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
|
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||||
import type ParcelWatcher from "@parcel/watcher"
|
import type ParcelWatcher from "@parcel/watcher"
|
||||||
@@ -7,8 +7,7 @@ import path from "path"
|
|||||||
import z from "zod"
|
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 { InstanceContext } from "@/effect/instance-context"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
@@ -61,107 +60,82 @@ export namespace FileWatcher {
|
|||||||
|
|
||||||
export const hasNativeBinding = () => !!watcher()
|
export const hasNativeBinding = () => !!watcher()
|
||||||
|
|
||||||
export interface Interface {
|
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make(
|
const instance = yield* InstanceContext
|
||||||
Effect.fn("FileWatcher.state")(
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
||||||
function* () {
|
|
||||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
|
||||||
|
|
||||||
log.info("init", { directory: Instance.directory })
|
log.info("init", { directory: instance.directory })
|
||||||
|
|
||||||
const backend = getBackend()
|
const backend = getBackend()
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
|
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
||||||
return
|
return Service.of({})
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = watcher()
|
const w = watcher()
|
||||||
if (!w) return
|
if (!w) return Service.of({})
|
||||||
|
|
||||||
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
|
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
||||||
|
|
||||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
|
||||||
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||||
if (err) return
|
if (err) return
|
||||||
for (const evt of evts) {
|
for (const evt of evts) {
|
||||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const subscribe = (dir: string, ignore: string[]) => {
|
const subscribe = (dir: string, ignore: string[]) => {
|
||||||
const pending = w.subscribe(dir, cb, { ignore, backend })
|
const pending = w.subscribe(dir, cb, { ignore, backend })
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const sub = yield* Effect.promise(() => pending)
|
const sub = yield* Effect.promise(() => pending)
|
||||||
subs.push(sub)
|
subs.push(sub)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
|
||||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = yield* Effect.promise(() => Config.get())
|
|
||||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
|
||||||
|
|
||||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
|
||||||
yield* subscribe(Instance.directory, [
|
|
||||||
...FileIgnore.PATTERNS,
|
|
||||||
...cfgIgnores,
|
|
||||||
...protecteds(Instance.directory),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
|
||||||
const result = yield* Effect.promise(() =>
|
|
||||||
git(["rev-parse", "--git-dir"], {
|
|
||||||
cwd: Instance.project.worktree,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const vcsDir =
|
|
||||||
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
|
||||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
|
||||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
|
||||||
(entry) => entry !== "HEAD",
|
|
||||||
)
|
|
||||||
yield* subscribe(vcsDir, ignore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||||
|
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
return Service.of({
|
const cfg = yield* Effect.promise(() => Config.get())
|
||||||
init: Effect.fn("FileWatcher.init")(function* () {
|
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||||
yield* InstanceState.get(state)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||||
|
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
|
||||||
|
}
|
||||||
|
|
||||||
export function init() {
|
if (instance.project.vcs === "git") {
|
||||||
return runPromise((svc) => svc.init())
|
const result = yield* Effect.promise(() =>
|
||||||
}
|
git(["rev-parse", "--git-dir"], {
|
||||||
|
cwd: instance.project.worktree,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
|
||||||
|
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||||
|
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||||
|
(entry) => entry !== "HEAD",
|
||||||
|
)
|
||||||
|
yield* subscribe(vcsDir, ignore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Service.of({})
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||||
|
return Effect.succeed(Service.of({}))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).pipe(Layer.orDie, Layer.fresh)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export namespace Flag {
|
|||||||
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
||||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||||
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
|
|
||||||
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
|
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
|
||||||
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
|
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
|
||||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||||
@@ -70,7 +69,6 @@ export namespace Flag {
|
|||||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||||
export const OPENCODE_DB = process.env["OPENCODE_DB"]
|
|
||||||
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
||||||
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
|
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
|
||||||
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
|
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
|
||||||
|
|||||||
@@ -1,182 +1,16 @@
|
|||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { Format as S } from "./service"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import path from "path"
|
|
||||||
import { mergeDeep } from "remeda"
|
|
||||||
import z from "zod"
|
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { File } from "../file"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Process } from "../util/process"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import * as Formatter from "./formatter"
|
|
||||||
|
|
||||||
export namespace Format {
|
export namespace Format {
|
||||||
const log = Log.create({ service: "format" })
|
export const Status = S.Status
|
||||||
|
export type Status = S.Status
|
||||||
|
|
||||||
export const Status = z
|
export type Interface = S.Interface
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
extensions: z.string().array(),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FormatterStatus",
|
|
||||||
})
|
|
||||||
export type Status = z.infer<typeof Status>
|
|
||||||
|
|
||||||
export interface Interface {
|
export const Service = S.Service
|
||||||
readonly init: () => Effect.Effect<void>
|
export const layer = S.layer
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* InstanceState.make(
|
|
||||||
Effect.fn("Format.state")(function* (_ctx) {
|
|
||||||
const enabled: Record<string, boolean> = {}
|
|
||||||
const formatters: Record<string, Formatter.Info> = {}
|
|
||||||
|
|
||||||
const cfg = yield* Effect.promise(() => Config.get())
|
|
||||||
|
|
||||||
if (cfg.formatter !== false) {
|
|
||||||
for (const item of Object.values(Formatter)) {
|
|
||||||
formatters[item.name] = item
|
|
||||||
}
|
|
||||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
|
||||||
if (item.disabled) {
|
|
||||||
delete formatters[name]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const info = mergeDeep(formatters[name] ?? {}, {
|
|
||||||
command: [],
|
|
||||||
extensions: [],
|
|
||||||
...item,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (info.command.length === 0) continue
|
|
||||||
|
|
||||||
formatters[name] = {
|
|
||||||
...info,
|
|
||||||
name,
|
|
||||||
enabled: async () => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.info("all formatters are disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isEnabled(item: Formatter.Info) {
|
|
||||||
let status = enabled[item.name]
|
|
||||||
if (status === undefined) {
|
|
||||||
status = await item.enabled()
|
|
||||||
enabled[item.name] = status
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFormatter(ext: string) {
|
|
||||||
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
|
|
||||||
const checks = await Promise.all(
|
|
||||||
matching.map(async (item) => {
|
|
||||||
log.info("checking", { name: item.name, ext })
|
|
||||||
const on = await isEnabled(item)
|
|
||||||
if (on) {
|
|
||||||
log.info("enabled", { name: item.name, ext })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
item,
|
|
||||||
enabled: on,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return checks.filter((x) => x.enabled).map((x) => x.item)
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.acquireRelease(
|
|
||||||
Effect.sync(() =>
|
|
||||||
Bus.subscribe(
|
|
||||||
File.Event.Edited,
|
|
||||||
Instance.bind(async (payload) => {
|
|
||||||
const file = payload.properties.file
|
|
||||||
log.info("formatting", { file })
|
|
||||||
const ext = path.extname(file)
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
return {
|
|
||||||
formatters,
|
|
||||||
isEnabled,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const init = Effect.fn("Format.init")(function* () {
|
|
||||||
yield* InstanceState.get(state)
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("Format.status")(function* () {
|
|
||||||
const { formatters, isEnabled } = yield* InstanceState.get(state)
|
|
||||||
const result: Status[] = []
|
|
||||||
for (const formatter of Object.values(formatters)) {
|
|
||||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
|
||||||
result.push({
|
|
||||||
name: formatter.name,
|
|
||||||
extensions: formatter.extensions,
|
|
||||||
enabled: isOn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ init, status })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export async function init() {
|
|
||||||
return runPromise((s) => s.init())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromise((s) => s.status())
|
return runPromiseInstance(S.Service.use((s) => s.status()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import path from "path"
|
||||||
|
import { mergeDeep } from "remeda"
|
||||||
|
import z from "zod"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { File } from "../file/service"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Process } from "../util/process"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import * as Formatter from "./formatter"
|
||||||
|
|
||||||
|
export namespace Format {
|
||||||
|
const log = Log.create({ service: "format" })
|
||||||
|
|
||||||
|
export const Status = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
extensions: z.string().array(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FormatterStatus",
|
||||||
|
})
|
||||||
|
export type Status = z.infer<typeof Status>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly status: () => Effect.Effect<Status[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const instance = yield* InstanceContext
|
||||||
|
|
||||||
|
const enabled: Record<string, boolean> = {}
|
||||||
|
const formatters: Record<string, Formatter.Info> = {}
|
||||||
|
|
||||||
|
const cfg = yield* Effect.promise(() => Config.get())
|
||||||
|
|
||||||
|
if (cfg.formatter !== false) {
|
||||||
|
for (const item of Object.values(Formatter)) {
|
||||||
|
formatters[item.name] = item
|
||||||
|
}
|
||||||
|
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||||
|
if (item.disabled) {
|
||||||
|
delete formatters[name]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const info = mergeDeep(formatters[name] ?? {}, {
|
||||||
|
command: [],
|
||||||
|
extensions: [],
|
||||||
|
...item,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (info.command.length === 0) continue
|
||||||
|
|
||||||
|
formatters[name] = {
|
||||||
|
...info,
|
||||||
|
name,
|
||||||
|
enabled: async () => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("all formatters are disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isEnabled(item: Formatter.Info) {
|
||||||
|
let status = enabled[item.name]
|
||||||
|
if (status === undefined) {
|
||||||
|
status = await item.enabled()
|
||||||
|
enabled[item.name] = status
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFormatter(ext: string) {
|
||||||
|
const result = []
|
||||||
|
for (const item of Object.values(formatters)) {
|
||||||
|
log.info("checking", { name: item.name, ext })
|
||||||
|
if (!item.extensions.includes(ext)) continue
|
||||||
|
if (!(await isEnabled(item))) continue
|
||||||
|
log.info("enabled", { name: item.name, ext })
|
||||||
|
result.push(item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.acquireRelease(
|
||||||
|
Effect.sync(() =>
|
||||||
|
Bus.subscribe(
|
||||||
|
File.Event.Edited,
|
||||||
|
Instance.bind(async (payload) => {
|
||||||
|
const file = payload.properties.file
|
||||||
|
log.info("formatting", { file })
|
||||||
|
const ext = path.extname(file)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
const status = Effect.fn("Format.status")(function* () {
|
||||||
|
const result: Status[] = []
|
||||||
|
for (const formatter of Object.values(formatters)) {
|
||||||
|
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||||
|
result.push({
|
||||||
|
name: formatter.name,
|
||||||
|
extensions: formatter.extensions,
|
||||||
|
enabled: isOn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ status })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.fresh)
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeChildProcessSpawner, 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 { makeRunPromise } 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"
|
||||||
@@ -15,15 +14,11 @@ declare global {
|
|||||||
const OPENCODE_CHANNEL: string
|
const OPENCODE_CHANNEL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
import semver from "semver"
|
|
||||||
|
|
||||||
export namespace Installation {
|
export namespace Installation {
|
||||||
const log = Log.create({ service: "installation" })
|
const log = Log.create({ service: "installation" })
|
||||||
|
|
||||||
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
|
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
|
||||||
|
|
||||||
export type ReleaseType = "patch" | "minor" | "major"
|
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Updated: BusEvent.define(
|
Updated: BusEvent.define(
|
||||||
"installation.updated",
|
"installation.updated",
|
||||||
@@ -39,17 +34,6 @@ export namespace Installation {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReleaseType(current: string, latest: string): ReleaseType {
|
|
||||||
const currMajor = semver.major(current)
|
|
||||||
const currMinor = semver.minor(current)
|
|
||||||
const newMajor = semver.major(latest)
|
|
||||||
const newMinor = semver.minor(latest)
|
|
||||||
|
|
||||||
if (newMajor > currMajor) return "major"
|
|
||||||
if (newMinor > currMinor) return "minor"
|
|
||||||
return "patch"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
@@ -309,7 +293,7 @@ export namespace Installation {
|
|||||||
result = yield* run(["scoop", "install", `opencode@${target}`])
|
result = yield* run(["scoop", "install", `opencode@${target}`])
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
|
throw new Error(`Unknown method: ${m}`)
|
||||||
}
|
}
|
||||||
if (!result || result.code !== 0) {
|
if (!result || result.code !== 0) {
|
||||||
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
||||||
@@ -345,21 +329,27 @@ export namespace Installation {
|
|||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
// Legacy adapters — dynamic import avoids circular dependency since
|
||||||
|
// foundational modules (db.ts, provider/models.ts) import Installation
|
||||||
|
// at load time, and runtime transitively loads those same modules.
|
||||||
|
async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
|
||||||
|
const { runtime } = await import("@/effect/runtime")
|
||||||
|
return runtime.runPromise(Service.use(f))
|
||||||
|
}
|
||||||
|
|
||||||
export async function info(): Promise<Info> {
|
export function info(): Promise<Info> {
|
||||||
return runPromise((svc) => svc.info())
|
return runPromise((svc) => svc.info())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function method(): Promise<Method> {
|
export function method(): Promise<Method> {
|
||||||
return runPromise((svc) => svc.method())
|
return runPromise((svc) => svc.method())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function latest(installMethod?: Method): Promise<string> {
|
export function latest(installMethod?: Method): Promise<string> {
|
||||||
return runPromise((svc) => svc.latest(installMethod))
|
return runPromise((svc) => svc.latest(installMethod))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upgrade(m: Method, target: string): Promise<void> {
|
export function upgrade(m: Method, target: string): Promise<void> {
|
||||||
return runPromise((svc) => svc.upgrade(m, target))
|
return runPromise((svc) => svc.upgrade(m, target))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,322 +1,52 @@
|
|||||||
import { Bus } from "@/bus"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { fn } from "@/util/fn"
|
||||||
import { Config } from "@/config/config"
|
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { ProjectID } from "@/project/schema"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
|
||||||
import { PermissionTable } from "@/session/session.sql"
|
|
||||||
import { Database, eq } from "@/storage/db"
|
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import { Wildcard } from "@/util/wildcard"
|
|
||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
|
||||||
import os from "os"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { evaluate as evalRule } from "./evaluate"
|
import { Permission as S } from "./service"
|
||||||
import { PermissionID } from "./schema"
|
|
||||||
|
|
||||||
export namespace Permission {
|
export namespace PermissionNext {
|
||||||
const log = Log.create({ service: "permission" })
|
export const Action = S.Action
|
||||||
|
export type Action = S.Action
|
||||||
|
|
||||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
export const Rule = S.Rule
|
||||||
ref: "PermissionAction",
|
export type Rule = S.Rule
|
||||||
})
|
|
||||||
export type Action = z.infer<typeof Action>
|
|
||||||
|
|
||||||
export const Rule = z
|
export const Ruleset = S.Ruleset
|
||||||
.object({
|
export type Ruleset = S.Ruleset
|
||||||
permission: z.string(),
|
|
||||||
pattern: z.string(),
|
|
||||||
action: Action,
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRule",
|
|
||||||
})
|
|
||||||
export type Rule = z.infer<typeof Rule>
|
|
||||||
|
|
||||||
export const Ruleset = Rule.array().meta({
|
export const Request = S.Request
|
||||||
ref: "PermissionRuleset",
|
export type Request = S.Request
|
||||||
})
|
|
||||||
export type Ruleset = z.infer<typeof Ruleset>
|
|
||||||
|
|
||||||
export const Request = z
|
export const Reply = S.Reply
|
||||||
.object({
|
export type Reply = S.Reply
|
||||||
id: PermissionID.zod,
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
permission: z.string(),
|
|
||||||
patterns: z.string().array(),
|
|
||||||
metadata: z.record(z.string(), z.any()),
|
|
||||||
always: z.string().array(),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRequest",
|
|
||||||
})
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Reply = z.enum(["once", "always", "reject"])
|
export const Approval = S.Approval
|
||||||
export type Reply = z.infer<typeof Reply>
|
export type Approval = z.infer<typeof S.Approval>
|
||||||
|
|
||||||
export const Approval = z.object({
|
export const Event = S.Event
|
||||||
projectID: ProjectID.zod,
|
|
||||||
patterns: z.string().array(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Event = {
|
export const RejectedError = S.RejectedError
|
||||||
Asked: BusEvent.define("permission.asked", Request),
|
export const CorrectedError = S.CorrectedError
|
||||||
Replied: BusEvent.define(
|
export const DeniedError = S.DeniedError
|
||||||
"permission.replied",
|
export type Error = S.Error
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
export const AskInput = S.AskInput
|
||||||
override get message() {
|
export const ReplyInput = S.ReplyInput
|
||||||
return "The user rejected permission to use this specific tool call."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
export type Interface = S.Interface
|
||||||
feedback: Schema.String,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
export const Service = S.Service
|
||||||
ruleset: Schema.Any,
|
export const layer = S.layer
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Error = DeniedError | RejectedError | CorrectedError
|
export const evaluate = S.evaluate
|
||||||
|
export const fromConfig = S.fromConfig
|
||||||
|
export const merge = S.merge
|
||||||
|
export const disabled = S.disabled
|
||||||
|
|
||||||
export const AskInput = Request.partial({ id: true }).extend({
|
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input))))
|
||||||
ruleset: Ruleset,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ReplyInput = z.object({
|
export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input))))
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
message: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
|
||||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<Request[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
pending: Map<PermissionID, PendingEntry>
|
|
||||||
approved: Ruleset
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
|
||||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
|
||||||
return evalRule(permission, pattern, ...rulesets)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("Permission.state")(function* (ctx) {
|
|
||||||
const row = Database.use((db) =>
|
|
||||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
|
||||||
)
|
|
||||||
const state = {
|
|
||||||
pending: new Map<PermissionID, PendingEntry>(),
|
|
||||||
approved: row?.data ?? [],
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
for (const item of state.pending.values()) {
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
state.pending.clear()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return state
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
|
||||||
const { approved, pending } = yield* InstanceState.get(state)
|
|
||||||
const { ruleset, ...request } = input
|
|
||||||
let needsAsk = false
|
|
||||||
|
|
||||||
for (const pattern of request.patterns) {
|
|
||||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
|
||||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
|
||||||
if (rule.action === "deny") {
|
|
||||||
return yield* new DeniedError({
|
|
||||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (rule.action === "allow") continue
|
|
||||||
needsAsk = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!needsAsk) return
|
|
||||||
|
|
||||||
const id = request.id ?? PermissionID.ascending()
|
|
||||||
const info: Request = {
|
|
||||||
id,
|
|
||||||
...request,
|
|
||||||
}
|
|
||||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
|
||||||
pending.set(id, { info, deferred })
|
|
||||||
void Bus.publish(Event.Asked, info)
|
|
||||||
return yield* Effect.ensuring(
|
|
||||||
Deferred.await(deferred),
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
|
||||||
const { approved, pending } = yield* InstanceState.get(state)
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) return
|
|
||||||
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
reply: input.reply,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (input.reply === "reject") {
|
|
||||||
yield* Deferred.fail(
|
|
||||||
existing.deferred,
|
|
||||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
pending.delete(id)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "reject",
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Deferred.succeed(existing.deferred, undefined)
|
|
||||||
if (input.reply === "once") return
|
|
||||||
|
|
||||||
for (const pattern of existing.info.always) {
|
|
||||||
approved.push({
|
|
||||||
permission: existing.info.permission,
|
|
||||||
pattern,
|
|
||||||
action: "allow",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
const ok = item.info.patterns.every(
|
|
||||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
|
||||||
)
|
|
||||||
if (!ok) continue
|
|
||||||
pending.delete(id)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "always",
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(item.deferred, undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Permission.list")(function* () {
|
|
||||||
const pending = (yield* InstanceState.get(state)).pending
|
|
||||||
return Array.from(pending.values(), (item) => item.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ ask, reply, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function expand(pattern: string): string {
|
|
||||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
|
||||||
if (pattern === "~") return os.homedir()
|
|
||||||
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
|
||||||
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
|
||||||
return pattern
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromConfig(permission: Config.Permission) {
|
|
||||||
const ruleset: Ruleset = []
|
|
||||||
for (const [key, value] of Object.entries(permission)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ruleset.push(
|
|
||||||
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ruleset
|
|
||||||
}
|
|
||||||
|
|
||||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
|
||||||
return rulesets.flat()
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
|
||||||
|
|
||||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
|
||||||
const result = new Set<string>()
|
|
||||||
for (const tool of tools) {
|
|
||||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
|
||||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
|
||||||
if (!rule) continue
|
|
||||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export async function ask(input: z.infer<typeof AskInput>) {
|
|
||||||
return runPromise((s) => s.ask(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reply(input: z.infer<typeof ReplyInput>) {
|
|
||||||
return runPromise((s) => s.reply(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return runPromise((s) => s.list())
|
return runPromiseInstance(S.Service.use((s) => s.list()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import { ProjectID } from "@/project/schema"
|
||||||
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
import { PermissionTable } from "@/session/session.sql"
|
||||||
|
import { Database, eq } from "@/storage/db"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { Wildcard } from "@/util/wildcard"
|
||||||
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
|
import os from "os"
|
||||||
|
import z from "zod"
|
||||||
|
import { evaluate as evalRule } from "./evaluate"
|
||||||
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
|
export namespace Permission {
|
||||||
|
const log = Log.create({ service: "permission" })
|
||||||
|
|
||||||
|
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||||
|
ref: "PermissionAction",
|
||||||
|
})
|
||||||
|
export type Action = z.infer<typeof Action>
|
||||||
|
|
||||||
|
export const Rule = z
|
||||||
|
.object({
|
||||||
|
permission: z.string(),
|
||||||
|
pattern: z.string(),
|
||||||
|
action: Action,
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRule",
|
||||||
|
})
|
||||||
|
export type Rule = z.infer<typeof Rule>
|
||||||
|
|
||||||
|
export const Ruleset = Rule.array().meta({
|
||||||
|
ref: "PermissionRuleset",
|
||||||
|
})
|
||||||
|
export type Ruleset = z.infer<typeof Ruleset>
|
||||||
|
|
||||||
|
export const Request = z
|
||||||
|
.object({
|
||||||
|
id: PermissionID.zod,
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
permission: z.string(),
|
||||||
|
patterns: z.string().array(),
|
||||||
|
metadata: z.record(z.string(), z.any()),
|
||||||
|
always: z.string().array(),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRequest",
|
||||||
|
})
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
|
export const Reply = z.enum(["once", "always", "reject"])
|
||||||
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
|
export const Approval = z.object({
|
||||||
|
projectID: ProjectID.zod,
|
||||||
|
patterns: z.string().array(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("permission.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"permission.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user rejected permission to use this specific tool call."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||||
|
feedback: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||||
|
ruleset: Schema.Any,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Error = DeniedError | RejectedError | CorrectedError
|
||||||
|
|
||||||
|
export const AskInput = Request.partial({ id: true }).extend({
|
||||||
|
ruleset: Ruleset,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ReplyInput = z.object({
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
message: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
||||||
|
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
|
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||||
|
return evalRule(permission, pattern, ...rulesets)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { project } = yield* InstanceContext
|
||||||
|
const row = Database.use((db) =>
|
||||||
|
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
||||||
|
)
|
||||||
|
const pending = new Map<PermissionID, PendingEntry>()
|
||||||
|
const approved: Ruleset = row?.data ?? []
|
||||||
|
|
||||||
|
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||||
|
const { ruleset, ...request } = input
|
||||||
|
let needsAsk = false
|
||||||
|
|
||||||
|
for (const pattern of request.patterns) {
|
||||||
|
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||||
|
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||||
|
if (rule.action === "deny") {
|
||||||
|
return yield* new DeniedError({
|
||||||
|
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (rule.action === "allow") continue
|
||||||
|
needsAsk = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsAsk) return
|
||||||
|
|
||||||
|
const id = request.id ?? PermissionID.ascending()
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
...request,
|
||||||
|
}
|
||||||
|
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
void Bus.publish(Event.Asked, info)
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
reply: input.reply,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.reply === "reject") {
|
||||||
|
yield* Deferred.fail(
|
||||||
|
existing.deferred,
|
||||||
|
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
pending.delete(id)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "reject",
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Deferred.succeed(existing.deferred, undefined)
|
||||||
|
if (input.reply === "once") return
|
||||||
|
|
||||||
|
for (const pattern of existing.info.always) {
|
||||||
|
approved.push({
|
||||||
|
permission: existing.info.permission,
|
||||||
|
pattern,
|
||||||
|
action: "allow",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
const ok = item.info.patterns.every(
|
||||||
|
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||||
|
)
|
||||||
|
if (!ok) continue
|
||||||
|
pending.delete(id)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "always",
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(item.deferred, undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Permission.list")(function* () {
|
||||||
|
return Array.from(pending.values(), (item) => item.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ask, reply, list })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.fresh)
|
||||||
|
|
||||||
|
function expand(pattern: string): string {
|
||||||
|
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||||
|
if (pattern === "~") return os.homedir()
|
||||||
|
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
||||||
|
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromConfig(permission: Config.Permission) {
|
||||||
|
const ruleset: Ruleset = []
|
||||||
|
for (const [key, value] of Object.entries(permission)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ruleset.push(
|
||||||
|
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||||
|
return rulesets.flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||||
|
|
||||||
|
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||||
|
const result = new Set<string>()
|
||||||
|
for (const tool of tools) {
|
||||||
|
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||||
|
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||||
|
if (!rule) continue
|
||||||
|
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,19 @@
|
|||||||
import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
|
import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
|
||||||
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 { Session } from "../session"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { CopilotAuthPlugin } from "./copilot"
|
|
||||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
||||||
type State = {
|
|
||||||
hooks: Hooks[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
|
||||||
type TriggerName = {
|
|
||||||
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
|
|
||||||
}[keyof Hooks]
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly trigger: <
|
readonly trigger: <
|
||||||
Name extends TriggerName,
|
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
|
||||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||||
>(
|
>(
|
||||||
@@ -43,130 +27,129 @@ export namespace Plugin {
|
|||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||||
|
|
||||||
// Built-in plugins that are directly imported (not installed from npm)
|
|
||||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
|
||||||
|
|
||||||
// Old npm package names for plugins that are now built-in — skip if users still have them in config
|
|
||||||
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cache = yield* InstanceState.make<State>(
|
const instance = yield* InstanceContext
|
||||||
Effect.fn("Plugin.state")(function* (ctx) {
|
const hooks: Hooks[] = []
|
||||||
const hooks: Hooks[] = []
|
let task: Promise<void> | undefined
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
const load = Effect.fn("Plugin.load")(function* () {
|
||||||
const client = createOpencodeClient({
|
yield* Effect.promise(async () => {
|
||||||
baseUrl: "http://localhost:4096",
|
const [{ Config }, { Server }, codex, copilot, gitlab] = await Promise.all([
|
||||||
directory: ctx.directory,
|
import("../config/config"),
|
||||||
headers: Flag.OPENCODE_SERVER_PASSWORD
|
import("../server/server"),
|
||||||
? {
|
import("./codex"),
|
||||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
import("./copilot"),
|
||||||
}
|
import("opencode-gitlab-auth"),
|
||||||
: undefined,
|
])
|
||||||
fetch: async (...args) => Server.Default().fetch(...args),
|
const internal: PluginInstance[] = [codex.CodexAuthPlugin, copilot.CopilotAuthPlugin, gitlab.gitlabAuthPlugin]
|
||||||
|
const client = createOpencodeClient({
|
||||||
|
baseUrl: "http://localhost:4096",
|
||||||
|
directory: instance.directory,
|
||||||
|
headers: Flag.OPENCODE_SERVER_PASSWORD
|
||||||
|
? {
|
||||||
|
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
fetch: async (...args) => Server.Default().fetch(...args),
|
||||||
|
})
|
||||||
|
const config = await Config.get()
|
||||||
|
const input: PluginInput = {
|
||||||
|
client,
|
||||||
|
project: instance.project,
|
||||||
|
worktree: instance.worktree,
|
||||||
|
directory: instance.directory,
|
||||||
|
get serverUrl(): URL {
|
||||||
|
return Server.url ?? new URL("http://localhost:4096")
|
||||||
|
},
|
||||||
|
$: Bun.$,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plugin of internal) {
|
||||||
|
log.info("loading internal plugin", { name: plugin.name })
|
||||||
|
const init = await plugin(input).catch((err) => {
|
||||||
|
log.error("failed to load internal plugin", { name: plugin.name, error: err })
|
||||||
})
|
})
|
||||||
const cfg = await Config.get()
|
if (init) hooks.push(init)
|
||||||
const input: PluginInput = {
|
}
|
||||||
client,
|
|
||||||
project: ctx.project,
|
|
||||||
worktree: ctx.worktree,
|
|
||||||
directory: ctx.directory,
|
|
||||||
get serverUrl(): URL {
|
|
||||||
return Server.url ?? new URL("http://localhost:4096")
|
|
||||||
},
|
|
||||||
$: Bun.$,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const plugin of INTERNAL_PLUGINS) {
|
let plugins = config.plugin ?? []
|
||||||
log.info("loading internal plugin", { name: plugin.name })
|
if (plugins.length) await Config.waitForDependencies()
|
||||||
const init = await plugin(input).catch((err) => {
|
|
||||||
log.error("failed to load internal plugin", { name: plugin.name, error: err })
|
|
||||||
})
|
|
||||||
if (init) hooks.push(init)
|
|
||||||
}
|
|
||||||
|
|
||||||
let plugins = cfg.plugin ?? []
|
for (let plugin of plugins) {
|
||||||
if (plugins.length) await Config.waitForDependencies()
|
// ignore old codex plugin since it is supported first party now
|
||||||
|
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
||||||
for (let plugin of plugins) {
|
log.info("loading plugin", { path: plugin })
|
||||||
if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue
|
if (!plugin.startsWith("file://")) {
|
||||||
log.info("loading plugin", { path: plugin })
|
const lastAtIndex = plugin.lastIndexOf("@")
|
||||||
if (!plugin.startsWith("file://")) {
|
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
||||||
const idx = plugin.lastIndexOf("@")
|
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
||||||
const pkg = idx > 0 ? plugin.substring(0, idx) : plugin
|
plugin = await BunProc.install(pkg, version).catch((err) => {
|
||||||
const version = idx > 0 ? plugin.substring(idx + 1) : "latest"
|
const cause = err instanceof Error ? err.cause : err
|
||||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||||
const cause = err instanceof Error ? err.cause : err
|
log.error("failed to install plugin", { pkg, version, error: detail })
|
||||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
void import("../session").then(({ Session }) =>
|
||||||
log.error("failed to install plugin", { pkg, version, error: detail })
|
|
||||||
Bus.publish(Session.Event.Error, {
|
Bus.publish(Session.Event.Error, {
|
||||||
error: new NamedError.Unknown({
|
error: new NamedError.Unknown({
|
||||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
||||||
}).toObject(),
|
}).toObject(),
|
||||||
})
|
}),
|
||||||
return ""
|
)
|
||||||
})
|
return ""
|
||||||
if (!plugin) continue
|
})
|
||||||
}
|
if (!plugin) continue
|
||||||
|
}
|
||||||
// Prevent duplicate initialization when plugins export the same function
|
// Prevent duplicate initialization when plugins export the same function
|
||||||
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
||||||
// Object.entries(mod) would return both entries pointing to the same function reference.
|
// Object.entries(mod) would return both entries pointing to the same function reference.
|
||||||
await import(plugin)
|
await import(plugin)
|
||||||
.then(async (mod) => {
|
.then(async (mod) => {
|
||||||
const seen = new Set<PluginInstance>()
|
const seen = new Set<PluginInstance>()
|
||||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||||
if (seen.has(fn)) continue
|
if (seen.has(fn)) continue
|
||||||
seen.add(fn)
|
seen.add(fn)
|
||||||
hooks.push(await fn(input))
|
hooks.push(await fn(input))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
log.error("failed to load plugin", { path: plugin, error: message })
|
log.error("failed to load plugin", { path: plugin, error: message })
|
||||||
|
void import("../session").then(({ Session }) =>
|
||||||
Bus.publish(Session.Event.Error, {
|
Bus.publish(Session.Event.Error, {
|
||||||
error: new NamedError.Unknown({
|
error: new NamedError.Unknown({
|
||||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
message: `Failed to load plugin ${plugin}: ${message}`,
|
||||||
}).toObject(),
|
}).toObject(),
|
||||||
})
|
}),
|
||||||
})
|
)
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Notify plugins of current config
|
const ensure = Effect.fn("Plugin.ensure")(function* () {
|
||||||
for (const hook of hooks) {
|
yield* Effect.promise(() => {
|
||||||
await (hook as any).config?.(cfg)
|
task ??= Effect.runPromise(
|
||||||
}
|
load().pipe(Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause })))),
|
||||||
})
|
|
||||||
|
|
||||||
// Subscribe to bus events, clean up when scope is closed
|
|
||||||
yield* Effect.acquireRelease(
|
|
||||||
Effect.sync(() =>
|
|
||||||
Bus.subscribeAll(async (input) => {
|
|
||||||
for (const hook of hooks) {
|
|
||||||
hook["event"]?.({ event: input })
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
(unsub) => Effect.sync(unsub),
|
|
||||||
)
|
)
|
||||||
|
return task
|
||||||
return { hooks }
|
})
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const trigger = Effect.fn("Plugin.trigger")(function* <
|
const trigger = Effect.fn("Plugin.trigger")(function* <
|
||||||
Name extends TriggerName,
|
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
|
||||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||||
>(name: Name, input: Input, output: Output) {
|
>(name: Name, input: Input, output: Output) {
|
||||||
if (!name) return output
|
if (!name) return output
|
||||||
const state = yield* InstanceState.get(cache)
|
yield* ensure()
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
for (const hook of state.hooks) {
|
for (const hook of hooks) {
|
||||||
const fn = hook[name] as any
|
const fn = hook[name]
|
||||||
if (!fn) continue
|
if (!fn) continue
|
||||||
|
// @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
|
||||||
|
// give up.
|
||||||
|
// try-counter: 2
|
||||||
await fn(input, output)
|
await fn(input, output)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -174,33 +157,50 @@ export namespace Plugin {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Plugin.list")(function* () {
|
const list = Effect.fn("Plugin.list")(function* () {
|
||||||
const state = yield* InstanceState.get(cache)
|
yield* ensure()
|
||||||
return state.hooks
|
return hooks
|
||||||
})
|
})
|
||||||
|
|
||||||
const init = Effect.fn("Plugin.init")(function* () {
|
const init = Effect.fn("Plugin.init")(function* () {
|
||||||
yield* InstanceState.get(cache)
|
yield* ensure()
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
const { Config } = await import("../config/config")
|
||||||
|
const config = await Config.get()
|
||||||
|
for (const hook of hooks) {
|
||||||
|
await (hook as any).config?.(config)
|
||||||
|
}
|
||||||
|
Bus.subscribeAll(async (input) => {
|
||||||
|
for (const hook of hooks) {
|
||||||
|
hook["event"]?.({
|
||||||
|
event: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ trigger, list, init })
|
return Service.of({ trigger, list, init })
|
||||||
}),
|
}),
|
||||||
)
|
).pipe(Layer.fresh)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
async function run<A, E>(effect: Effect.Effect<A, E, Service>) {
|
||||||
|
const { runPromiseInstance } = await import("@/effect/runtime")
|
||||||
|
return runPromiseInstance(effect)
|
||||||
|
}
|
||||||
|
|
||||||
export async function trigger<
|
export async function trigger<
|
||||||
Name extends TriggerName,
|
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
|
||||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||||
>(name: Name, input: Input, output: Output): Promise<Output> {
|
>(name: Name, input: Input, output: Output): Promise<Output> {
|
||||||
return runPromise((svc) => svc.trigger(name, input, output))
|
return run(Service.use((svc) => svc.trigger(name, input, output)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(): Promise<Hooks[]> {
|
export async function list(): Promise<Hooks[]> {
|
||||||
return runPromise((svc) => svc.list())
|
return run(Service.use((svc) => svc.list()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
return runPromise((svc) => svc.init())
|
return run(Service.use((svc) => svc.init()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import { Format } from "../format"
|
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
import { FileWatcher } from "../file/watcher"
|
|
||||||
import { Snapshot } from "../snapshot"
|
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { Vcs } from "./vcs"
|
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
@@ -16,12 +12,8 @@ export async function InstanceBootstrap() {
|
|||||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||||
await Plugin.init()
|
await Plugin.init()
|
||||||
ShareNext.init()
|
ShareNext.init()
|
||||||
Format.init()
|
|
||||||
await LSP.init()
|
await LSP.init()
|
||||||
File.init()
|
File.init()
|
||||||
FileWatcher.init()
|
|
||||||
Vcs.init()
|
|
||||||
Snapshot.init()
|
|
||||||
|
|
||||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||||
if (payload.properties.name === Command.Default.INIT) {
|
if (payload.properties.name === Command.Default.INIT) {
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import { Context } from "../util/context"
|
|||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
|
||||||
export interface Shape {
|
interface Context {
|
||||||
directory: string
|
directory: string
|
||||||
worktree: string
|
worktree: string
|
||||||
project: Project.Info
|
project: Project.Info
|
||||||
}
|
}
|
||||||
const context = Context.create<Shape>("instance")
|
const context = Context.create<Context>("instance")
|
||||||
const cache = new Map<string, Promise<Shape>>()
|
const cache = new Map<string, Promise<Context>>()
|
||||||
|
|
||||||
const disposal = {
|
const disposal = {
|
||||||
all: undefined as Promise<void> | undefined,
|
all: undefined as Promise<void> | undefined,
|
||||||
@@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function track(directory: string, next: Promise<Shape>) {
|
function track(directory: string, next: Promise<Context>) {
|
||||||
const task = next.catch((error) => {
|
const task = next.catch((error) => {
|
||||||
if (cache.get(directory) === task) cache.delete(directory)
|
if (cache.get(directory) === task) cache.delete(directory)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user