mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68409c9471 | |||
| d1f7df0b97 | |||
| b1e6ea3e88 | |||
| addc33212b | |||
| 7053c58d35 | |||
| a277fb4049 | |||
| 59cc1ebc27 | |||
| df260fee45 | |||
| 3b2a1e8415 | |||
| 6f9c7812d3 |
+1
-2
@@ -10,8 +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
|
-danieljoshuanazareth
|
||||||
edemaine
|
edemaine
|
||||||
-florianleibert
|
-florianleibert
|
||||||
@@ -25,3 +23,4 @@ r44vc0rp
|
|||||||
rekram1-node
|
rekram1-node
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
|
-danieljoshuanazareth
|
||||||
|
|||||||
@@ -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,7 +41,6 @@
|
|||||||
"@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:",
|
||||||
@@ -79,7 +78,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 +112,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 +129,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 +139,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 +163,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 +187,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 +220,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 +251,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 +280,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 +296,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 +337,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 +420,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 +444,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 +455,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 +490,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 +536,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 +547,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 +611,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 +1447,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 +1889,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=="],
|
||||||
@@ -2060,7 +2057,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 +2453,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 +5203,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-WXQ4b1hHFk2vDWz41fJmj+z0twee6r0YR0JGH0iw0ZI=",
|
||||||
"aarch64-linux": "sha256-lnwaGSEirl9izskDooB/xQ0ZdirW0t3/S+OoOnfYaoQ=",
|
"aarch64-linux": "sha256-YIdnfkHGLfUq3cZkycvL7DQ8BvC5X+VDia7UTLgJBx8=",
|
||||||
"aarch64-darwin": "sha256-RDxxW9NMlGMIdIxTsbOYVqxunflkILv2dA7JqjnJgm4=",
|
"aarch64-darwin": "sha256-bMUeI1LcBYgKBwG92WazTgxNryZF2Gv9iQgK46Pd+3A=",
|
||||||
"x86_64-darwin": "sha256-1tvvktu2NRg6N6ASuKzqzcEmMrzH3/LFey0Vxr4E8zg="
|
"x86_64-darwin": "sha256-fJbEd1j8ObZ2OMykYVU6v0uI1gy2eoCFIZ9ovuiNeLY="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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,7 +51,6 @@
|
|||||||
"@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:",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -276,7 +276,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) => {
|
||||||
@@ -1989,10 +1975,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 +2368,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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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 { useMutation } from "@tanstack/solid-query"
|
||||||
@@ -30,7 +30,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 +250,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) {
|
||||||
@@ -660,15 +676,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>
|
||||||
@@ -894,6 +912,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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -101,8 +101,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",
|
||||||
|
|||||||
@@ -75,40 +75,6 @@ export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
|||||||
|
|
||||||
See `Auth.ZodInfo` for the canonical example.
|
See `Auth.ZodInfo` for the canonical example.
|
||||||
|
|
||||||
## 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
|
|
||||||
const cache =
|
|
||||||
yield *
|
|
||||||
InstanceState.make<State>(
|
|
||||||
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.
|
|
||||||
- **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
|
||||||
|
|
||||||
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||||
@@ -157,12 +123,11 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
|||||||
- [x] `Truncate` — `tool/truncate.ts`
|
- [x] `Truncate` — `tool/truncate.ts`
|
||||||
- [x] `Vcs` — `project/vcs.ts`
|
- [x] `Vcs` — `project/vcs.ts`
|
||||||
- [x] `Discovery` — `skill/discovery.ts`
|
- [x] `Discovery` — `skill/discovery.ts`
|
||||||
- [x] `SessionStatus`
|
|
||||||
|
|
||||||
Still open and likely worth migrating:
|
Still open and likely worth migrating:
|
||||||
|
|
||||||
- [x] `Plugin`
|
- [ ] `Plugin`
|
||||||
- [x] `ToolRegistry`
|
- [ ] `ToolRegistry`
|
||||||
- [ ] `Pty`
|
- [ ] `Pty`
|
||||||
- [ ] `Worktree`
|
- [ ] `Worktree`
|
||||||
- [ ] `Bus`
|
- [ ] `Bus`
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -15,15 +15,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 +35,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(),
|
||||||
|
|||||||
@@ -5,202 +5,140 @@ import { Log } from "../util/log"
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { Server } from "../server/server"
|
import { Server } from "../server/server"
|
||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { CodexAuthPlugin } from "./codex"
|
import { CodexAuthPlugin } from "./codex"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { CopilotAuthPlugin } from "./copilot"
|
import { CopilotAuthPlugin } from "./copilot"
|
||||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
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 {
|
|
||||||
readonly trigger: <
|
|
||||||
Name extends TriggerName,
|
|
||||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
|
||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
|
||||||
>(
|
|
||||||
name: Name,
|
|
||||||
input: Input,
|
|
||||||
output: Output,
|
|
||||||
) => Effect.Effect<Output>
|
|
||||||
readonly list: () => Effect.Effect<Hooks[]>
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
|
||||||
|
|
||||||
// Built-in plugins that are directly imported (not installed from npm)
|
// Built-in plugins that are directly imported (not installed from npm)
|
||||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
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 state = Instance.state(async () => {
|
||||||
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
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 hooks: Hooks[] = []
|
||||||
|
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.$,
|
||||||
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
for (const plugin of INTERNAL_PLUGINS) {
|
||||||
Service,
|
log.info("loading internal plugin", { name: plugin.name })
|
||||||
Effect.gen(function* () {
|
const init = await plugin(input).catch((err) => {
|
||||||
const cache = yield* InstanceState.make<State>(
|
log.error("failed to load internal plugin", { name: plugin.name, error: err })
|
||||||
Effect.fn("Plugin.state")(function* (ctx) {
|
})
|
||||||
const hooks: Hooks[] = []
|
if (init) hooks.push(init)
|
||||||
|
}
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
let plugins = config.plugin ?? []
|
||||||
const client = createOpencodeClient({
|
if (plugins.length) await Config.waitForDependencies()
|
||||||
baseUrl: "http://localhost:4096",
|
|
||||||
directory: ctx.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 cfg = await Config.get()
|
|
||||||
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) {
|
for (let plugin of plugins) {
|
||||||
log.info("loading internal plugin", { name: plugin.name })
|
// ignore old codex plugin since it is supported first party now
|
||||||
const init = await plugin(input).catch((err) => {
|
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
||||||
log.error("failed to load internal plugin", { name: plugin.name, error: err })
|
log.info("loading plugin", { path: plugin })
|
||||||
})
|
if (!plugin.startsWith("file://")) {
|
||||||
if (init) hooks.push(init)
|
const lastAtIndex = plugin.lastIndexOf("@")
|
||||||
}
|
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
||||||
|
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
||||||
let plugins = cfg.plugin ?? []
|
plugin = await BunProc.install(pkg, version).catch((err) => {
|
||||||
if (plugins.length) await Config.waitForDependencies()
|
const cause = err instanceof Error ? err.cause : err
|
||||||
|
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||||
for (let plugin of plugins) {
|
log.error("failed to install plugin", { pkg, version, error: detail })
|
||||||
if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue
|
Bus.publish(Session.Event.Error, {
|
||||||
log.info("loading plugin", { path: plugin })
|
error: new NamedError.Unknown({
|
||||||
if (!plugin.startsWith("file://")) {
|
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
||||||
const idx = plugin.lastIndexOf("@")
|
}).toObject(),
|
||||||
const pkg = idx > 0 ? plugin.substring(0, idx) : plugin
|
|
||||||
const version = idx > 0 ? plugin.substring(idx + 1) : "latest"
|
|
||||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
|
||||||
const cause = err instanceof Error ? err.cause : err
|
|
||||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
|
||||||
log.error("failed to install plugin", { pkg, version, error: detail })
|
|
||||||
Bus.publish(Session.Event.Error, {
|
|
||||||
error: new NamedError.Unknown({
|
|
||||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
if (!plugin) continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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`).
|
|
||||||
// Object.entries(mod) would return both entries pointing to the same function reference.
|
|
||||||
await import(plugin)
|
|
||||||
.then(async (mod) => {
|
|
||||||
const seen = new Set<PluginInstance>()
|
|
||||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
|
||||||
if (seen.has(fn)) continue
|
|
||||||
seen.add(fn)
|
|
||||||
hooks.push(await fn(input))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
log.error("failed to load plugin", { path: plugin, error: message })
|
|
||||||
Bus.publish(Session.Event.Error, {
|
|
||||||
error: new NamedError.Unknown({
|
|
||||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify plugins of current config
|
|
||||||
for (const hook of hooks) {
|
|
||||||
await (hook as any).config?.(cfg)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
return ""
|
||||||
// Subscribe to bus events, clean up when scope is closed
|
})
|
||||||
yield* Effect.acquireRelease(
|
if (!plugin) continue
|
||||||
Effect.sync(() =>
|
}
|
||||||
Bus.subscribeAll(async (input) => {
|
// Prevent duplicate initialization when plugins export the same function
|
||||||
for (const hook of hooks) {
|
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
||||||
hook["event"]?.({ event: input })
|
// Object.entries(mod) would return both entries pointing to the same function reference.
|
||||||
}
|
await import(plugin)
|
||||||
}),
|
.then(async (mod) => {
|
||||||
),
|
const seen = new Set<PluginInstance>()
|
||||||
(unsub) => Effect.sync(unsub),
|
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||||
)
|
if (seen.has(fn)) continue
|
||||||
|
seen.add(fn)
|
||||||
return { hooks }
|
hooks.push(await fn(input))
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const trigger = Effect.fn("Plugin.trigger")(function* <
|
|
||||||
Name extends TriggerName,
|
|
||||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
|
||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
|
||||||
>(name: Name, input: Input, output: Output) {
|
|
||||||
if (!name) return output
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
for (const hook of state.hooks) {
|
|
||||||
const fn = hook[name] as any
|
|
||||||
if (!fn) continue
|
|
||||||
await fn(input, output)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return output
|
.catch((err) => {
|
||||||
})
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
log.error("failed to load plugin", { path: plugin, error: message })
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
error: new NamedError.Unknown({
|
||||||
|
message: `Failed to load plugin ${plugin}: ${message}`,
|
||||||
|
}).toObject(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const list = Effect.fn("Plugin.list")(function* () {
|
return {
|
||||||
const state = yield* InstanceState.get(cache)
|
hooks,
|
||||||
return state.hooks
|
input,
|
||||||
})
|
}
|
||||||
|
})
|
||||||
const init = Effect.fn("Plugin.init")(function* () {
|
|
||||||
yield* InstanceState.get(cache)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ trigger, list, init })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
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))
|
if (!name) return output
|
||||||
|
for (const hook of await state().then((x) => x.hooks)) {
|
||||||
|
const fn = hook[name]
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(): Promise<Hooks[]> {
|
export async function list() {
|
||||||
return runPromise((svc) => svc.list())
|
return state().then((x) => x.hooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
return runPromise((svc) => svc.init())
|
const hooks = await state().then((x) => x.hooks)
|
||||||
|
const config = await Config.get()
|
||||||
|
for (const hook of hooks) {
|
||||||
|
// @ts-expect-error this is because we haven't moved plugin to sdk v2
|
||||||
|
await hook.config?.(config)
|
||||||
|
}
|
||||||
|
Bus.subscribeAll(async (input) => {
|
||||||
|
const hooks = await state().then((x) => x.hooks)
|
||||||
|
for (const hook of hooks) {
|
||||||
|
hook["event"]?.({
|
||||||
|
event: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+213
-289
@@ -1,16 +1,13 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { type IPty } from "bun-pty"
|
import { type IPty } from "bun-pty"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
import { lazy } from "@opencode-ai/util/lazy"
|
import { lazy } from "@opencode-ai/util/lazy"
|
||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { PtyID } from "./schema"
|
import { PtyID } from "./schema"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
|
|
||||||
export namespace Pty {
|
export namespace Pty {
|
||||||
const log = Log.create({ service: "pty" })
|
const log = Log.create({ service: "pty" })
|
||||||
@@ -26,20 +23,6 @@ export namespace Pty {
|
|||||||
close: (code?: number, reason?: string) => void
|
close: (code?: number, reason?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Active = {
|
|
||||||
info: Info
|
|
||||||
process: IPty
|
|
||||||
buffer: string
|
|
||||||
bufferCursor: number
|
|
||||||
cursor: number
|
|
||||||
subscribers: Map<unknown, Socket>
|
|
||||||
}
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
dir: string
|
|
||||||
sessions: Map<PtyID, Active>
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||||
const meta = (cursor: number) => {
|
const meta = (cursor: number) => {
|
||||||
const json = JSON.stringify({ cursor })
|
const json = JSON.stringify({ cursor })
|
||||||
@@ -98,300 +81,241 @@ export namespace Pty {
|
|||||||
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
|
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
interface ActiveSession {
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
info: Info
|
||||||
readonly get: (id: PtyID) => Effect.Effect<Info | undefined>
|
process: IPty
|
||||||
readonly create: (input: CreateInput) => Effect.Effect<Info>
|
buffer: string
|
||||||
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info | undefined>
|
bufferCursor: number
|
||||||
readonly remove: (id: PtyID) => Effect.Effect<void>
|
cursor: number
|
||||||
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void>
|
subscribers: Map<unknown, Socket>
|
||||||
readonly write: (id: PtyID, data: string) => Effect.Effect<void>
|
|
||||||
readonly connect: (
|
|
||||||
id: PtyID,
|
|
||||||
ws: Socket,
|
|
||||||
cursor?: number,
|
|
||||||
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Pty") {}
|
const state = Instance.state(
|
||||||
|
() => new Map<PtyID, ActiveSession>(),
|
||||||
export const layer = Layer.effect(
|
async (sessions) => {
|
||||||
Service,
|
for (const session of sessions.values()) {
|
||||||
Effect.gen(function* () {
|
|
||||||
function teardown(session: Active) {
|
|
||||||
try {
|
try {
|
||||||
session.process.kill()
|
session.process.kill()
|
||||||
} catch {}
|
} catch {}
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
for (const [key, ws] of session.subscribers.entries()) {
|
||||||
try {
|
try {
|
||||||
if (ws.data === key) ws.close()
|
if (ws.data === key) ws.close()
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
session.subscribers.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("Pty.state")(function* (ctx) {
|
|
||||||
const state = {
|
|
||||||
dir: ctx.directory,
|
|
||||||
sessions: new Map<PtyID, Active>(),
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
for (const session of state.sessions.values()) {
|
|
||||||
teardown(session)
|
|
||||||
}
|
|
||||||
state.sessions.clear()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return state
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
const session = state.sessions.get(id)
|
|
||||||
if (!session) return
|
|
||||||
state.sessions.delete(id)
|
|
||||||
log.info("removing session", { id })
|
|
||||||
teardown(session)
|
|
||||||
void Bus.publish(Event.Deleted, { id: session.info.id })
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Pty.list")(function* () {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
return Array.from(state.sessions.values()).map((session) => session.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
return state.sessions.get(id)?.info
|
|
||||||
})
|
|
||||||
|
|
||||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
return yield* Effect.promise(async () => {
|
|
||||||
const id = PtyID.ascending()
|
|
||||||
const command = input.command || Shell.preferred()
|
|
||||||
const args = input.args || []
|
|
||||||
if (command.endsWith("sh")) {
|
|
||||||
args.push("-l")
|
|
||||||
}
|
|
||||||
|
|
||||||
const cwd = input.cwd || state.dir
|
|
||||||
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
...input.env,
|
|
||||||
...shellEnv.env,
|
|
||||||
TERM: "xterm-256color",
|
|
||||||
OPENCODE_TERMINAL: "1",
|
|
||||||
} as Record<string, string>
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
env.LC_ALL = "C.UTF-8"
|
|
||||||
env.LC_CTYPE = "C.UTF-8"
|
|
||||||
env.LANG = "C.UTF-8"
|
|
||||||
}
|
|
||||||
log.info("creating session", { id, cmd: command, args, cwd })
|
|
||||||
|
|
||||||
const spawn = await pty()
|
|
||||||
const proc = spawn(command, args, {
|
|
||||||
name: "xterm-256color",
|
|
||||||
cwd,
|
|
||||||
env,
|
|
||||||
})
|
|
||||||
|
|
||||||
const info = {
|
|
||||||
id,
|
|
||||||
title: input.title || `Terminal ${id.slice(-4)}`,
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
cwd,
|
|
||||||
status: "running",
|
|
||||||
pid: proc.pid,
|
|
||||||
} as const
|
|
||||||
const session: Active = {
|
|
||||||
info,
|
|
||||||
process: proc,
|
|
||||||
buffer: "",
|
|
||||||
bufferCursor: 0,
|
|
||||||
cursor: 0,
|
|
||||||
subscribers: new Map(),
|
|
||||||
}
|
|
||||||
state.sessions.set(id, session)
|
|
||||||
proc.onData(
|
|
||||||
Instance.bind((chunk) => {
|
|
||||||
session.cursor += chunk.length
|
|
||||||
|
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
|
||||||
if (ws.readyState !== 1) {
|
|
||||||
session.subscribers.delete(key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (ws.data !== key) {
|
|
||||||
session.subscribers.delete(key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ws.send(chunk)
|
|
||||||
} catch {
|
|
||||||
session.subscribers.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
session.buffer += chunk
|
|
||||||
if (session.buffer.length <= BUFFER_LIMIT) return
|
|
||||||
const excess = session.buffer.length - BUFFER_LIMIT
|
|
||||||
session.buffer = session.buffer.slice(excess)
|
|
||||||
session.bufferCursor += excess
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
proc.onExit(
|
|
||||||
Instance.bind(({ exitCode }) => {
|
|
||||||
if (session.info.status === "exited") return
|
|
||||||
log.info("session exited", { id, exitCode })
|
|
||||||
session.info.status = "exited"
|
|
||||||
void Bus.publish(Event.Exited, { id, exitCode })
|
|
||||||
Effect.runFork(remove(id))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await Bus.publish(Event.Created, { info })
|
|
||||||
return info
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
const session = state.sessions.get(id)
|
|
||||||
if (!session) return
|
|
||||||
if (input.title) {
|
|
||||||
session.info.title = input.title
|
|
||||||
}
|
|
||||||
if (input.size) {
|
|
||||||
session.process.resize(input.size.cols, input.size.rows)
|
|
||||||
}
|
|
||||||
yield* Effect.promise(() => Bus.publish(Event.Updated, { info: session.info }))
|
|
||||||
return session.info
|
|
||||||
})
|
|
||||||
|
|
||||||
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
const session = state.sessions.get(id)
|
|
||||||
if (session && session.info.status === "running") {
|
|
||||||
session.process.resize(cols, rows)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
const session = state.sessions.get(id)
|
|
||||||
if (session && session.info.status === "running") {
|
|
||||||
session.process.write(data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
const session = state.sessions.get(id)
|
|
||||||
if (!session) {
|
|
||||||
ws.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.info("client connected to session", { id })
|
|
||||||
|
|
||||||
// Use ws.data as the unique key for this connection lifecycle.
|
|
||||||
// If ws.data is undefined, fallback to ws object.
|
|
||||||
const key = ws.data && typeof ws.data === "object" ? ws.data : ws
|
|
||||||
// Optionally cleanup if the key somehow exists
|
|
||||||
session.subscribers.delete(key)
|
|
||||||
session.subscribers.set(key, ws)
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
session.subscribers.delete(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = session.bufferCursor
|
|
||||||
const end = session.cursor
|
|
||||||
const from =
|
|
||||||
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
|
|
||||||
|
|
||||||
const data = (() => {
|
|
||||||
if (!session.buffer) return ""
|
|
||||||
if (from >= end) return ""
|
|
||||||
const offset = Math.max(0, from - start)
|
|
||||||
if (offset >= session.buffer.length) return ""
|
|
||||||
return session.buffer.slice(offset)
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
|
|
||||||
ws.send(data.slice(i, i + BUFFER_CHUNK))
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
cleanup()
|
// ignore
|
||||||
ws.close()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
try {
|
sessions.clear()
|
||||||
ws.send(meta(end))
|
},
|
||||||
} catch {
|
|
||||||
cleanup()
|
|
||||||
ws.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
onMessage: (message: string | ArrayBuffer) => {
|
|
||||||
session.process.write(String(message))
|
|
||||||
},
|
|
||||||
onClose: () => {
|
|
||||||
log.info("client disconnected from session", { id })
|
|
||||||
cleanup()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ list, get, create, update, remove, resize, write, connect })
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
export function list() {
|
||||||
|
return Array.from(state().values()).map((s) => s.info)
|
||||||
export async function list() {
|
|
||||||
return runPromise((svc) => svc.list())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get(id: PtyID) {
|
export function get(id: PtyID) {
|
||||||
return runPromise((svc) => svc.get(id))
|
return state().get(id)?.info
|
||||||
}
|
|
||||||
|
|
||||||
export async function resize(id: PtyID, cols: number, rows: number) {
|
|
||||||
return runPromise((svc) => svc.resize(id, cols, rows))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function write(id: PtyID, data: string) {
|
|
||||||
return runPromise((svc) => svc.write(id, data))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connect(id: PtyID, ws: Socket, cursor?: number) {
|
|
||||||
return runPromise((svc) => svc.connect(id, ws, cursor))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(input: CreateInput) {
|
export async function create(input: CreateInput) {
|
||||||
return runPromise((svc) => svc.create(input))
|
const id = PtyID.ascending()
|
||||||
|
const command = input.command || Shell.preferred()
|
||||||
|
const args = input.args || []
|
||||||
|
if (command.endsWith("sh")) {
|
||||||
|
args.push("-l")
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = input.cwd || Instance.directory
|
||||||
|
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
...input.env,
|
||||||
|
...shellEnv.env,
|
||||||
|
TERM: "xterm-256color",
|
||||||
|
OPENCODE_TERMINAL: "1",
|
||||||
|
} as Record<string, string>
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
env.LC_ALL = "C.UTF-8"
|
||||||
|
env.LC_CTYPE = "C.UTF-8"
|
||||||
|
env.LANG = "C.UTF-8"
|
||||||
|
}
|
||||||
|
log.info("creating session", { id, cmd: command, args, cwd })
|
||||||
|
|
||||||
|
const spawn = await pty()
|
||||||
|
const ptyProcess = spawn(command, args, {
|
||||||
|
name: "xterm-256color",
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
})
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
id,
|
||||||
|
title: input.title || `Terminal ${id.slice(-4)}`,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
status: "running",
|
||||||
|
pid: ptyProcess.pid,
|
||||||
|
} as const
|
||||||
|
const session: ActiveSession = {
|
||||||
|
info,
|
||||||
|
process: ptyProcess,
|
||||||
|
buffer: "",
|
||||||
|
bufferCursor: 0,
|
||||||
|
cursor: 0,
|
||||||
|
subscribers: new Map(),
|
||||||
|
}
|
||||||
|
state().set(id, session)
|
||||||
|
ptyProcess.onData(
|
||||||
|
Instance.bind((chunk) => {
|
||||||
|
session.cursor += chunk.length
|
||||||
|
|
||||||
|
for (const [key, ws] of session.subscribers.entries()) {
|
||||||
|
if (ws.readyState !== 1) {
|
||||||
|
session.subscribers.delete(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws.data !== key) {
|
||||||
|
session.subscribers.delete(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws.send(chunk)
|
||||||
|
} catch {
|
||||||
|
session.subscribers.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.buffer += chunk
|
||||||
|
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||||
|
const excess = session.buffer.length - BUFFER_LIMIT
|
||||||
|
session.buffer = session.buffer.slice(excess)
|
||||||
|
session.bufferCursor += excess
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
ptyProcess.onExit(
|
||||||
|
Instance.bind(({ exitCode }) => {
|
||||||
|
if (session.info.status === "exited") return
|
||||||
|
log.info("session exited", { id, exitCode })
|
||||||
|
session.info.status = "exited"
|
||||||
|
Bus.publish(Event.Exited, { id, exitCode })
|
||||||
|
remove(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
Bus.publish(Event.Created, { info })
|
||||||
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(id: PtyID, input: UpdateInput) {
|
export async function update(id: PtyID, input: UpdateInput) {
|
||||||
return runPromise((svc) => svc.update(id, input))
|
const session = state().get(id)
|
||||||
|
if (!session) return
|
||||||
|
if (input.title) {
|
||||||
|
session.info.title = input.title
|
||||||
|
}
|
||||||
|
if (input.size) {
|
||||||
|
session.process.resize(input.size.cols, input.size.rows)
|
||||||
|
}
|
||||||
|
Bus.publish(Event.Updated, { info: session.info })
|
||||||
|
return session.info
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(id: PtyID) {
|
export async function remove(id: PtyID) {
|
||||||
return runPromise((svc) => svc.remove(id))
|
const session = state().get(id)
|
||||||
|
if (!session) return
|
||||||
|
state().delete(id)
|
||||||
|
log.info("removing session", { id })
|
||||||
|
try {
|
||||||
|
session.process.kill()
|
||||||
|
} catch {}
|
||||||
|
for (const [key, ws] of session.subscribers.entries()) {
|
||||||
|
try {
|
||||||
|
if (ws.data === key) ws.close()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.subscribers.clear()
|
||||||
|
Bus.publish(Event.Deleted, { id: session.info.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resize(id: PtyID, cols: number, rows: number) {
|
||||||
|
const session = state().get(id)
|
||||||
|
if (session && session.info.status === "running") {
|
||||||
|
session.process.resize(cols, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function write(id: PtyID, data: string) {
|
||||||
|
const session = state().get(id)
|
||||||
|
if (session && session.info.status === "running") {
|
||||||
|
session.process.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connect(id: PtyID, ws: Socket, cursor?: number) {
|
||||||
|
const session = state().get(id)
|
||||||
|
if (!session) {
|
||||||
|
ws.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info("client connected to session", { id })
|
||||||
|
|
||||||
|
// Use ws.data as the unique key for this connection lifecycle.
|
||||||
|
// If ws.data is undefined, fallback to ws object.
|
||||||
|
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
|
||||||
|
|
||||||
|
// Optionally cleanup if the key somehow exists
|
||||||
|
session.subscribers.delete(connectionKey)
|
||||||
|
session.subscribers.set(connectionKey, ws)
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
session.subscribers.delete(connectionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = session.bufferCursor
|
||||||
|
const end = session.cursor
|
||||||
|
|
||||||
|
const from =
|
||||||
|
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
|
||||||
|
|
||||||
|
const data = (() => {
|
||||||
|
if (!session.buffer) return ""
|
||||||
|
if (from >= end) return ""
|
||||||
|
const offset = Math.max(0, from - start)
|
||||||
|
if (offset >= session.buffer.length) return ""
|
||||||
|
return session.buffer.slice(offset)
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
|
||||||
|
ws.send(data.slice(i, i + BUFFER_CHUNK))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
cleanup()
|
||||||
|
ws.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws.send(meta(end))
|
||||||
|
} catch {
|
||||||
|
cleanup()
|
||||||
|
ws.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
onMessage: (message: string | ArrayBuffer) => {
|
||||||
|
session.process.write(String(message))
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
log.info("client disconnected from session", { id })
|
||||||
|
cleanup()
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||||
import { streamSSE } from "hono/streaming"
|
import { streamSSE } from "hono/streaming"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Bus } from "../../bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { AsyncQueue } from "@/util/queue"
|
import { AsyncQueue } from "@/util/queue"
|
||||||
@@ -196,62 +195,5 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
})
|
})
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/upgrade",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Upgrade opencode",
|
|
||||||
description: "Upgrade opencode to the specified version or latest if not specified.",
|
|
||||||
operationId: "global.upgrade",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Upgrade result",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(
|
|
||||||
z.union([
|
|
||||||
z.object({
|
|
||||||
success: z.literal(true),
|
|
||||||
version: z.string(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
success: z.literal(false),
|
|
||||||
error: z.string(),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"json",
|
|
||||||
z.object({
|
|
||||||
target: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const method = await Installation.method()
|
|
||||||
if (method === "unknown") {
|
|
||||||
return c.json({ success: false, error: "Unknown installation method" }, 400)
|
|
||||||
}
|
|
||||||
const target = c.req.valid("json").target || (await Installation.latest(method))
|
|
||||||
const result = await Installation.upgrade(method, target)
|
|
||||||
.then(() => ({ success: true as const, version: target }))
|
|
||||||
.catch((e) => ({ success: false as const, error: e instanceof Error ? e.message : String(e) }))
|
|
||||||
if (result.success) {
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: "global",
|
|
||||||
payload: {
|
|
||||||
type: Installation.Event.Updated.type,
|
|
||||||
properties: { version: target },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return c.json(result)
|
|
||||||
}
|
|
||||||
return c.json(result, 500)
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const PtyRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await Pty.list())
|
return c.json(Pty.list())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
@@ -75,7 +75,7 @@ export const PtyRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const info = await Pty.get(c.req.valid("param").ptyID)
|
const info = Pty.get(c.req.valid("param").ptyID)
|
||||||
if (!info) {
|
if (!info) {
|
||||||
throw new NotFoundError({ message: "Session not found" })
|
throw new NotFoundError({ message: "Session not found" })
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ export const PtyRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
upgradeWebSocket(async (c) => {
|
upgradeWebSocket((c) => {
|
||||||
const id = PtyID.zod.parse(c.req.param("ptyID"))
|
const id = PtyID.zod.parse(c.req.param("ptyID"))
|
||||||
const cursor = (() => {
|
const cursor = (() => {
|
||||||
const value = c.req.query("cursor")
|
const value = c.req.query("cursor")
|
||||||
@@ -159,8 +159,8 @@ export const PtyRoutes = lazy(() =>
|
|||||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
||||||
return parsed
|
return parsed
|
||||||
})()
|
})()
|
||||||
let handler: Awaited<ReturnType<typeof Pty.connect>>
|
let handler: ReturnType<typeof Pty.connect>
|
||||||
if (!(await Pty.get(id))) throw new Error("Session not found")
|
if (!Pty.get(id)) throw new Error("Session not found")
|
||||||
|
|
||||||
type Socket = {
|
type Socket = {
|
||||||
readyState: number
|
readyState: number
|
||||||
@@ -176,27 +176,17 @@ export const PtyRoutes = lazy(() =>
|
|||||||
return typeof (value as { readyState?: unknown }).readyState === "number"
|
return typeof (value as { readyState?: unknown }).readyState === "number"
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending: string[] = []
|
|
||||||
let ready = false
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async onOpen(_event, ws) {
|
onOpen(_event, ws) {
|
||||||
const socket = ws.raw
|
const socket = ws.raw
|
||||||
if (!isSocket(socket)) {
|
if (!isSocket(socket)) {
|
||||||
ws.close()
|
ws.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler = await Pty.connect(id, socket, cursor)
|
handler = Pty.connect(id, socket, cursor)
|
||||||
ready = true
|
|
||||||
for (const msg of pending) handler?.onMessage(msg)
|
|
||||||
pending.length = 0
|
|
||||||
},
|
},
|
||||||
onMessage(event) {
|
onMessage(event) {
|
||||||
if (typeof event.data !== "string") return
|
if (typeof event.data !== "string") return
|
||||||
if (!ready) {
|
|
||||||
pending.push(event.data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handler?.onMessage(event.data)
|
handler?.onMessage(event.data)
|
||||||
},
|
},
|
||||||
onClose() {
|
onClose() {
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export const SessionRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const result = await SessionStatus.list()
|
const result = SessionStatus.list()
|
||||||
return c.json(Object.fromEntries(result))
|
return c.json(result)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export namespace SessionProcessor {
|
|||||||
input.abort.throwIfAborted()
|
input.abort.throwIfAborted()
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
case "start":
|
case "start":
|
||||||
await SessionStatus.set(input.sessionID, { type: "busy" })
|
SessionStatus.set(input.sessionID, { type: "busy" })
|
||||||
break
|
break
|
||||||
|
|
||||||
case "reasoning-start":
|
case "reasoning-start":
|
||||||
@@ -368,7 +368,7 @@ export namespace SessionProcessor {
|
|||||||
if (retry !== undefined) {
|
if (retry !== undefined) {
|
||||||
attempt++
|
attempt++
|
||||||
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
|
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
|
||||||
await SessionStatus.set(input.sessionID, {
|
SessionStatus.set(input.sessionID, {
|
||||||
type: "retry",
|
type: "retry",
|
||||||
attempt,
|
attempt,
|
||||||
message: retry,
|
message: retry,
|
||||||
@@ -382,7 +382,7 @@ export namespace SessionProcessor {
|
|||||||
sessionID: input.assistantMessage.sessionID,
|
sessionID: input.assistantMessage.sessionID,
|
||||||
error: input.assistantMessage.error,
|
error: input.assistantMessage.error,
|
||||||
})
|
})
|
||||||
await SessionStatus.set(input.sessionID, { type: "idle" })
|
SessionStatus.set(input.sessionID, { type: "idle" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
|
|||||||
@@ -257,17 +257,17 @@ export namespace SessionPrompt {
|
|||||||
return s[sessionID].abort.signal
|
return s[sessionID].abort.signal
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancel(sessionID: SessionID) {
|
export function cancel(sessionID: SessionID) {
|
||||||
log.info("cancel", { sessionID })
|
log.info("cancel", { sessionID })
|
||||||
const s = state()
|
const s = state()
|
||||||
const match = s[sessionID]
|
const match = s[sessionID]
|
||||||
if (!match) {
|
if (!match) {
|
||||||
await SessionStatus.set(sessionID, { type: "idle" })
|
SessionStatus.set(sessionID, { type: "idle" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
match.abort.abort()
|
match.abort.abort()
|
||||||
delete s[sessionID]
|
delete s[sessionID]
|
||||||
await SessionStatus.set(sessionID, { type: "idle" })
|
SessionStatus.set(sessionID, { type: "idle" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ export namespace SessionPrompt {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await using _ = defer(() => cancel(sessionID))
|
using _ = defer(() => cancel(sessionID))
|
||||||
|
|
||||||
// Structured output state
|
// Structured output state
|
||||||
// Note: On session resumption, state is reset but outputFormat is preserved
|
// Note: On session resumption, state is reset but outputFormat is preserved
|
||||||
@@ -296,7 +296,7 @@ export namespace SessionPrompt {
|
|||||||
let step = 0
|
let step = 0
|
||||||
const session = await Session.get(sessionID)
|
const session = await Session.get(sessionID)
|
||||||
while (true) {
|
while (true) {
|
||||||
await SessionStatus.set(sessionID, { type: "busy" })
|
SessionStatus.set(sessionID, { type: "busy" })
|
||||||
log.info("loop", { step, sessionID })
|
log.info("loop", { step, sessionID })
|
||||||
if (abort.aborted) break
|
if (abort.aborted) break
|
||||||
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { Instance } from "@/project/instance"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { SessionID } from "./schema"
|
import { SessionID } from "./schema"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
export namespace SessionStatus {
|
export namespace SessionStatus {
|
||||||
@@ -44,56 +42,36 @@ export namespace SessionStatus {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
const state = Instance.state(() => {
|
||||||
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
|
const data: Record<string, Info> = {}
|
||||||
readonly list: () => Effect.Effect<Map<SessionID, Info>>
|
return data
|
||||||
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
|
})
|
||||||
|
|
||||||
|
export function get(sessionID: SessionID) {
|
||||||
|
return (
|
||||||
|
state()[sessionID] ?? {
|
||||||
|
type: "idle",
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionStatus") {}
|
export function list() {
|
||||||
|
return state()
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* InstanceState.make(
|
|
||||||
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
|
|
||||||
)
|
|
||||||
|
|
||||||
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
|
|
||||||
const data = yield* InstanceState.get(state)
|
|
||||||
return data.get(sessionID) ?? { type: "idle" as const }
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("SessionStatus.list")(function* () {
|
|
||||||
return new Map(yield* InstanceState.get(state))
|
|
||||||
})
|
|
||||||
|
|
||||||
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
|
|
||||||
const data = yield* InstanceState.get(state)
|
|
||||||
yield* Effect.promise(() => Bus.publish(Event.Status, { sessionID, status }))
|
|
||||||
if (status.type === "idle") {
|
|
||||||
yield* Effect.promise(() => Bus.publish(Event.Idle, { sessionID }))
|
|
||||||
data.delete(sessionID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.set(sessionID, status)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, list, set })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export async function get(sessionID: SessionID) {
|
|
||||||
return runPromise((svc) => svc.get(sessionID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list() {
|
export function set(sessionID: SessionID, status: Info) {
|
||||||
return runPromise((svc) => svc.list())
|
Bus.publish(Event.Status, {
|
||||||
}
|
sessionID,
|
||||||
|
status,
|
||||||
export async function set(sessionID: SessionID, status: Info) {
|
})
|
||||||
return runPromise((svc) => svc.set(sessionID, status))
|
if (status.type === "idle") {
|
||||||
|
// deprecated
|
||||||
|
Bus.publish(Event.Idle, {
|
||||||
|
sessionID,
|
||||||
|
})
|
||||||
|
delete state()[sessionID]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state()[sessionID] = status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-109
@@ -33,16 +33,6 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
|
|||||||
return text.replaceAll("\n", "\r\n")
|
return text.replaceAll("\n", "\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function stats(before: string, after: string) {
|
|
||||||
let additions = 0
|
|
||||||
let deletions = 0
|
|
||||||
for (const change of diffLines(before, after)) {
|
|
||||||
if (change.added) additions += change.count || 0
|
|
||||||
if (change.removed) deletions += change.count || 0
|
|
||||||
}
|
|
||||||
return { additions, deletions }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EditTool = Tool.define("edit", {
|
export const EditTool = Tool.define("edit", {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
@@ -125,16 +115,23 @@ export const EditTool = Tool.define("edit", {
|
|||||||
file: filePath,
|
file: filePath,
|
||||||
event: "change",
|
event: "change",
|
||||||
})
|
})
|
||||||
|
contentNew = await Filesystem.readText(filePath)
|
||||||
|
diff = trimDiff(
|
||||||
|
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||||
|
)
|
||||||
await FileTime.read(ctx.sessionID, filePath)
|
await FileTime.read(ctx.sessionID, filePath)
|
||||||
})
|
})
|
||||||
|
|
||||||
const count = stats(contentOld, contentNew)
|
|
||||||
const filediff: Snapshot.FileDiff = {
|
const filediff: Snapshot.FileDiff = {
|
||||||
file: filePath,
|
file: filePath,
|
||||||
before: contentOld,
|
before: contentOld,
|
||||||
after: contentNew,
|
after: contentNew,
|
||||||
additions: count.additions,
|
additions: 0,
|
||||||
deletions: count.deletions,
|
deletions: 0,
|
||||||
|
}
|
||||||
|
for (const change of diffLines(contentOld, contentNew)) {
|
||||||
|
if (change.added) filediff.additions += change.count || 0
|
||||||
|
if (change.removed) filediff.deletions += change.count || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.metadata({
|
ctx.metadata({
|
||||||
@@ -170,18 +167,7 @@ export const EditTool = Tool.define("edit", {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
type Prep = {
|
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
|
||||||
content: string
|
|
||||||
find: string
|
|
||||||
lines: string[]
|
|
||||||
finds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Replacer = (prep: Prep) => Generator<string, void, unknown>
|
|
||||||
|
|
||||||
function trimLastEmpty(lines: string[]) {
|
|
||||||
return lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// Similarity thresholds for block anchor fallback matching
|
// Similarity thresholds for block anchor fallback matching
|
||||||
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
||||||
@@ -195,36 +181,37 @@ function levenshtein(a: string, b: string): number {
|
|||||||
if (a === "" || b === "") {
|
if (a === "" || b === "") {
|
||||||
return Math.max(a.length, b.length)
|
return Math.max(a.length, b.length)
|
||||||
}
|
}
|
||||||
const [left, right] = a.length < b.length ? [a, b] : [b, a]
|
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
||||||
let prev = Array.from({ length: left.length + 1 }, (_, i) => i)
|
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
||||||
let next = Array.from({ length: left.length + 1 }, () => 0)
|
)
|
||||||
|
|
||||||
for (let i = 1; i <= right.length; i++) {
|
for (let i = 1; i <= a.length; i++) {
|
||||||
next[0] = i
|
for (let j = 1; j <= b.length; j++) {
|
||||||
for (let j = 1; j <= left.length; j++) {
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||||
const cost = right[i - 1] === left[j - 1] ? 0 : 1
|
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
||||||
next[j] = Math.min(next[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
|
|
||||||
}
|
}
|
||||||
;[prev, next] = [next, prev]
|
}
|
||||||
|
return matrix[a.length][b.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimpleReplacer: Replacer = function* (_content, find) {
|
||||||
|
yield find
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
||||||
|
const originalLines = content.split("\n")
|
||||||
|
const searchLines = find.split("\n")
|
||||||
|
|
||||||
|
if (searchLines[searchLines.length - 1] === "") {
|
||||||
|
searchLines.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
return prev[left.length]
|
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
||||||
}
|
|
||||||
|
|
||||||
export const SimpleReplacer: Replacer = function* (prep) {
|
|
||||||
yield prep.find
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LineTrimmedReplacer: Replacer = function* (prep) {
|
|
||||||
const original = prep.lines
|
|
||||||
const search = trimLastEmpty(prep.finds)
|
|
||||||
|
|
||||||
for (let i = 0; i <= original.length - search.length; i++) {
|
|
||||||
let matches = true
|
let matches = true
|
||||||
|
|
||||||
for (let j = 0; j < search.length; j++) {
|
for (let j = 0; j < searchLines.length; j++) {
|
||||||
const originalTrimmed = original[i + j].trim()
|
const originalTrimmed = originalLines[i + j].trim()
|
||||||
const searchTrimmed = search[j].trim()
|
const searchTrimmed = searchLines[j].trim()
|
||||||
|
|
||||||
if (originalTrimmed !== searchTrimmed) {
|
if (originalTrimmed !== searchTrimmed) {
|
||||||
matches = false
|
matches = false
|
||||||
@@ -235,42 +222,48 @@ export const LineTrimmedReplacer: Replacer = function* (prep) {
|
|||||||
if (matches) {
|
if (matches) {
|
||||||
let matchStartIndex = 0
|
let matchStartIndex = 0
|
||||||
for (let k = 0; k < i; k++) {
|
for (let k = 0; k < i; k++) {
|
||||||
matchStartIndex += original[k].length + 1
|
matchStartIndex += originalLines[k].length + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
let matchEndIndex = matchStartIndex
|
let matchEndIndex = matchStartIndex
|
||||||
for (let k = 0; k < search.length; k++) {
|
for (let k = 0; k < searchLines.length; k++) {
|
||||||
matchEndIndex += original[i + k].length
|
matchEndIndex += originalLines[i + k].length
|
||||||
if (k < search.length - 1) {
|
if (k < searchLines.length - 1) {
|
||||||
matchEndIndex += 1 // Add newline character except for the last line
|
matchEndIndex += 1 // Add newline character except for the last line
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
yield prep.content.substring(matchStartIndex, matchEndIndex)
|
yield content.substring(matchStartIndex, matchEndIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BlockAnchorReplacer: Replacer = function* (prep) {
|
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||||
const original = prep.lines
|
const originalLines = content.split("\n")
|
||||||
const search = trimLastEmpty(prep.finds)
|
const searchLines = find.split("\n")
|
||||||
|
|
||||||
if (search.length < 3) return
|
if (searchLines.length < 3) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const firstLineSearch = search[0].trim()
|
if (searchLines[searchLines.length - 1] === "") {
|
||||||
const lastLineSearch = search[search.length - 1].trim()
|
searchLines.pop()
|
||||||
const searchBlockSize = search.length
|
}
|
||||||
|
|
||||||
|
const firstLineSearch = searchLines[0].trim()
|
||||||
|
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||||
|
const searchBlockSize = searchLines.length
|
||||||
|
|
||||||
// Collect all candidate positions where both anchors match
|
// Collect all candidate positions where both anchors match
|
||||||
const candidates: Array<{ startLine: number; endLine: number }> = []
|
const candidates: Array<{ startLine: number; endLine: number }> = []
|
||||||
for (let i = 0; i < original.length; i++) {
|
for (let i = 0; i < originalLines.length; i++) {
|
||||||
if (original[i].trim() !== firstLineSearch) {
|
if (originalLines[i].trim() !== firstLineSearch) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for the matching last line after this first line
|
// Look for the matching last line after this first line
|
||||||
for (let j = i + 2; j < original.length; j++) {
|
for (let j = i + 2; j < originalLines.length; j++) {
|
||||||
if (original[j].trim() === lastLineSearch) {
|
if (originalLines[j].trim() === lastLineSearch) {
|
||||||
candidates.push({ startLine: i, endLine: j })
|
candidates.push({ startLine: i, endLine: j })
|
||||||
break // Only match the first occurrence of the last line
|
break // Only match the first occurrence of the last line
|
||||||
}
|
}
|
||||||
@@ -292,8 +285,8 @@ export const BlockAnchorReplacer: Replacer = function* (prep) {
|
|||||||
|
|
||||||
if (linesToCheck > 0) {
|
if (linesToCheck > 0) {
|
||||||
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
||||||
const originalLine = original[startLine + j].trim()
|
const originalLine = originalLines[startLine + j].trim()
|
||||||
const searchLine = search[j].trim()
|
const searchLine = searchLines[j].trim()
|
||||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||||
if (maxLen === 0) {
|
if (maxLen === 0) {
|
||||||
continue
|
continue
|
||||||
@@ -314,16 +307,16 @@ export const BlockAnchorReplacer: Replacer = function* (prep) {
|
|||||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||||
let matchStartIndex = 0
|
let matchStartIndex = 0
|
||||||
for (let k = 0; k < startLine; k++) {
|
for (let k = 0; k < startLine; k++) {
|
||||||
matchStartIndex += original[k].length + 1
|
matchStartIndex += originalLines[k].length + 1
|
||||||
}
|
}
|
||||||
let matchEndIndex = matchStartIndex
|
let matchEndIndex = matchStartIndex
|
||||||
for (let k = startLine; k <= endLine; k++) {
|
for (let k = startLine; k <= endLine; k++) {
|
||||||
matchEndIndex += original[k].length
|
matchEndIndex += originalLines[k].length
|
||||||
if (k < endLine) {
|
if (k < endLine) {
|
||||||
matchEndIndex += 1 // Add newline character except for the last line
|
matchEndIndex += 1 // Add newline character except for the last line
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
yield prep.content.substring(matchStartIndex, matchEndIndex)
|
yield content.substring(matchStartIndex, matchEndIndex)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -341,8 +334,8 @@ export const BlockAnchorReplacer: Replacer = function* (prep) {
|
|||||||
|
|
||||||
if (linesToCheck > 0) {
|
if (linesToCheck > 0) {
|
||||||
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
||||||
const originalLine = original[startLine + j].trim()
|
const originalLine = originalLines[startLine + j].trim()
|
||||||
const searchLine = search[j].trim()
|
const searchLine = searchLines[j].trim()
|
||||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||||
if (maxLen === 0) {
|
if (maxLen === 0) {
|
||||||
continue
|
continue
|
||||||
@@ -367,25 +360,25 @@ export const BlockAnchorReplacer: Replacer = function* (prep) {
|
|||||||
const { startLine, endLine } = bestMatch
|
const { startLine, endLine } = bestMatch
|
||||||
let matchStartIndex = 0
|
let matchStartIndex = 0
|
||||||
for (let k = 0; k < startLine; k++) {
|
for (let k = 0; k < startLine; k++) {
|
||||||
matchStartIndex += original[k].length + 1
|
matchStartIndex += originalLines[k].length + 1
|
||||||
}
|
}
|
||||||
let matchEndIndex = matchStartIndex
|
let matchEndIndex = matchStartIndex
|
||||||
for (let k = startLine; k <= endLine; k++) {
|
for (let k = startLine; k <= endLine; k++) {
|
||||||
matchEndIndex += original[k].length
|
matchEndIndex += originalLines[k].length
|
||||||
if (k < endLine) {
|
if (k < endLine) {
|
||||||
matchEndIndex += 1
|
matchEndIndex += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
yield prep.content.substring(matchStartIndex, matchEndIndex)
|
yield content.substring(matchStartIndex, matchEndIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
|
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
||||||
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
||||||
const normalizedFind = normalizeWhitespace(prep.find)
|
const normalizedFind = normalizeWhitespace(find)
|
||||||
|
|
||||||
// Handle single line matches
|
// Handle single line matches
|
||||||
const lines = prep.lines
|
const lines = content.split("\n")
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i]
|
const line = lines[i]
|
||||||
if (normalizeWhitespace(line) === normalizedFind) {
|
if (normalizeWhitespace(line) === normalizedFind) {
|
||||||
@@ -395,7 +388,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
|
|||||||
const normalizedLine = normalizeWhitespace(line)
|
const normalizedLine = normalizeWhitespace(line)
|
||||||
if (normalizedLine.includes(normalizedFind)) {
|
if (normalizedLine.includes(normalizedFind)) {
|
||||||
// Find the actual substring in the original line that matches
|
// Find the actual substring in the original line that matches
|
||||||
const words = prep.find.trim().split(/\s+/)
|
const words = find.trim().split(/\s+/)
|
||||||
if (words.length > 0) {
|
if (words.length > 0) {
|
||||||
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
|
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
|
||||||
try {
|
try {
|
||||||
@@ -413,7 +406,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle multi-line matches
|
// Handle multi-line matches
|
||||||
const findLines = prep.finds
|
const findLines = find.split("\n")
|
||||||
if (findLines.length > 1) {
|
if (findLines.length > 1) {
|
||||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||||
const block = lines.slice(i, i + findLines.length)
|
const block = lines.slice(i, i + findLines.length)
|
||||||
@@ -424,7 +417,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IndentationFlexibleReplacer: Replacer = function* (prep) {
|
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||||
const removeIndentation = (text: string) => {
|
const removeIndentation = (text: string) => {
|
||||||
const lines = text.split("\n")
|
const lines = text.split("\n")
|
||||||
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
||||||
@@ -440,9 +433,9 @@ export const IndentationFlexibleReplacer: Replacer = function* (prep) {
|
|||||||
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
|
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedFind = removeIndentation(prep.find)
|
const normalizedFind = removeIndentation(find)
|
||||||
const contentLines = prep.lines
|
const contentLines = content.split("\n")
|
||||||
const findLines = prep.finds
|
const findLines = find.split("\n")
|
||||||
|
|
||||||
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
||||||
const block = contentLines.slice(i, i + findLines.length).join("\n")
|
const block = contentLines.slice(i, i + findLines.length).join("\n")
|
||||||
@@ -452,7 +445,7 @@ export const IndentationFlexibleReplacer: Replacer = function* (prep) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EscapeNormalizedReplacer: Replacer = function* (prep) {
|
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
||||||
const unescapeString = (str: string): string => {
|
const unescapeString = (str: string): string => {
|
||||||
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
|
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
|
||||||
switch (capturedChar) {
|
switch (capturedChar) {
|
||||||
@@ -480,15 +473,15 @@ export const EscapeNormalizedReplacer: Replacer = function* (prep) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unescapedFind = unescapeString(prep.find)
|
const unescapedFind = unescapeString(find)
|
||||||
|
|
||||||
// Try direct match with unescaped find string
|
// Try direct match with unescaped find string
|
||||||
if (prep.content.includes(unescapedFind)) {
|
if (content.includes(unescapedFind)) {
|
||||||
yield unescapedFind
|
yield unescapedFind
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try finding escaped versions in content that match unescaped find
|
// Also try finding escaped versions in content that match unescaped find
|
||||||
const lines = prep.lines
|
const lines = content.split("\n")
|
||||||
const findLines = unescapedFind.split("\n")
|
const findLines = unescapedFind.split("\n")
|
||||||
|
|
||||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||||
@@ -501,36 +494,36 @@ export const EscapeNormalizedReplacer: Replacer = function* (prep) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MultiOccurrenceReplacer: Replacer = function* (prep) {
|
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
||||||
// This replacer yields all exact matches, allowing the replace function
|
// This replacer yields all exact matches, allowing the replace function
|
||||||
// to handle multiple occurrences based on replaceAll parameter
|
// to handle multiple occurrences based on replaceAll parameter
|
||||||
let startIndex = 0
|
let startIndex = 0
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const index = prep.content.indexOf(prep.find, startIndex)
|
const index = content.indexOf(find, startIndex)
|
||||||
if (index === -1) break
|
if (index === -1) break
|
||||||
|
|
||||||
yield prep.find
|
yield find
|
||||||
startIndex = index + prep.find.length
|
startIndex = index + find.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TrimmedBoundaryReplacer: Replacer = function* (prep) {
|
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
||||||
const trimmedFind = prep.find.trim()
|
const trimmedFind = find.trim()
|
||||||
|
|
||||||
if (trimmedFind === prep.find) {
|
if (trimmedFind === find) {
|
||||||
// Already trimmed, no point in trying
|
// Already trimmed, no point in trying
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find the trimmed version
|
// Try to find the trimmed version
|
||||||
if (prep.content.includes(trimmedFind)) {
|
if (content.includes(trimmedFind)) {
|
||||||
yield trimmedFind
|
yield trimmedFind
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try finding blocks where trimmed content matches
|
// Also try finding blocks where trimmed content matches
|
||||||
const lines = prep.lines
|
const lines = content.split("\n")
|
||||||
const findLines = prep.finds
|
const findLines = find.split("\n")
|
||||||
|
|
||||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||||
const block = lines.slice(i, i + findLines.length).join("\n")
|
const block = lines.slice(i, i + findLines.length).join("\n")
|
||||||
@@ -541,13 +534,19 @@ export const TrimmedBoundaryReplacer: Replacer = function* (prep) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContextAwareReplacer: Replacer = function* (prep) {
|
export const ContextAwareReplacer: Replacer = function* (content, find) {
|
||||||
const findLines = trimLastEmpty(prep.finds)
|
const findLines = find.split("\n")
|
||||||
if (findLines.length < 3) {
|
if (findLines.length < 3) {
|
||||||
// Need at least 3 lines to have meaningful context
|
// Need at least 3 lines to have meaningful context
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const contentLines = prep.lines
|
|
||||||
|
// Remove trailing empty line if present
|
||||||
|
if (findLines[findLines.length - 1] === "") {
|
||||||
|
findLines.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLines = content.split("\n")
|
||||||
|
|
||||||
// Extract first and last lines as context anchors
|
// Extract first and last lines as context anchors
|
||||||
const firstLine = findLines[0].trim()
|
const firstLine = findLines[0].trim()
|
||||||
@@ -636,13 +635,6 @@ export function replace(content: string, oldString: string, newString: string, r
|
|||||||
|
|
||||||
let notFound = true
|
let notFound = true
|
||||||
|
|
||||||
const prep = {
|
|
||||||
content,
|
|
||||||
find: oldString,
|
|
||||||
lines: content.split("\n"),
|
|
||||||
finds: oldString.split("\n"),
|
|
||||||
} satisfies Prep
|
|
||||||
|
|
||||||
for (const replacer of [
|
for (const replacer of [
|
||||||
SimpleReplacer,
|
SimpleReplacer,
|
||||||
LineTrimmedReplacer,
|
LineTrimmedReplacer,
|
||||||
@@ -654,7 +646,7 @@ export function replace(content: string, oldString: string, newString: string, r
|
|||||||
ContextAwareReplacer,
|
ContextAwareReplacer,
|
||||||
MultiOccurrenceReplacer,
|
MultiOccurrenceReplacer,
|
||||||
]) {
|
]) {
|
||||||
for (const search of replacer(prep)) {
|
for (const search of replacer(content, oldString)) {
|
||||||
const index = content.indexOf(search)
|
const index = content.indexOf(search)
|
||||||
if (index === -1) continue
|
if (index === -1) continue
|
||||||
notFound = false
|
notFound = false
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ const MAX_LINE_LENGTH = 2000
|
|||||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||||
const MAX_BYTES = 50 * 1024
|
const MAX_BYTES = 50 * 1024
|
||||||
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
||||||
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024
|
|
||||||
const MAX_ATTACHMENT_LABEL = `${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
|
|
||||||
|
|
||||||
export const ReadTool = Tool.define("read", {
|
export const ReadTool = Tool.define("read", {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -124,9 +122,6 @@ export const ReadTool = Tool.define("read", {
|
|||||||
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
||||||
const isPdf = mime === "application/pdf"
|
const isPdf = mime === "application/pdf"
|
||||||
if (isImage || isPdf) {
|
if (isImage || isPdf) {
|
||||||
if (Number(stat.size) > MAX_ATTACHMENT_BYTES) {
|
|
||||||
throw new Error(`Cannot attach file larger than ${MAX_ATTACHMENT_LABEL}: ${filepath}`)
|
|
||||||
}
|
|
||||||
const msg = `${isImage ? "Image" : "PDF"} read successfully`
|
const msg = `${isImage ? "Image" : "PDF"} read successfully`
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@@ -172,7 +167,7 @@ export const ReadTool = Tool.define("read", {
|
|||||||
|
|
||||||
if (raw.length >= limit) {
|
if (raw.length >= limit) {
|
||||||
hasMoreLines = true
|
hasMoreLines = true
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
||||||
@@ -203,6 +198,7 @@ export const ReadTool = Tool.define("read", {
|
|||||||
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
||||||
output += content.join("\n")
|
output += content.join("\n")
|
||||||
|
|
||||||
|
const totalLines = lines
|
||||||
const lastReadLine = offset + raw.length - 1
|
const lastReadLine = offset + raw.length - 1
|
||||||
const nextOffset = lastReadLine + 1
|
const nextOffset = lastReadLine + 1
|
||||||
const truncated = hasMoreLines || truncatedByBytes
|
const truncated = hasMoreLines || truncatedByBytes
|
||||||
@@ -210,9 +206,9 @@ export const ReadTool = Tool.define("read", {
|
|||||||
if (truncatedByBytes) {
|
if (truncatedByBytes) {
|
||||||
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
|
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
|
||||||
} else if (hasMoreLines) {
|
} else if (hasMoreLines) {
|
||||||
output += `\n\n(Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
|
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
|
||||||
} else {
|
} else {
|
||||||
output += `\n\n(End of file - total ${lines} lines)`
|
output += `\n\n(End of file - total ${totalLines} lines)`
|
||||||
}
|
}
|
||||||
output += "\n</content>"
|
output += "\n</content>"
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { GrepTool } from "./grep"
|
|||||||
import { BatchTool } from "./batch"
|
import { BatchTool } from "./batch"
|
||||||
import { ReadTool } from "./read"
|
import { ReadTool } from "./read"
|
||||||
import { TaskTool } from "./task"
|
import { TaskTool } from "./task"
|
||||||
import { TodoWriteTool } from "./todo"
|
import { TodoWriteTool, TodoReadTool } from "./todo"
|
||||||
import { WebFetchTool } from "./webfetch"
|
import { WebFetchTool } from "./webfetch"
|
||||||
import { WriteTool } from "./write"
|
import { WriteTool } from "./write"
|
||||||
import { InvalidTool } from "./invalid"
|
import { InvalidTool } from "./invalid"
|
||||||
import { SkillTool } from "./skill"
|
import { SkillTool } from "./skill"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
@@ -26,186 +27,106 @@ import { Flag } from "@/flag/flag"
|
|||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { LspTool } from "./lsp"
|
import { LspTool } from "./lsp"
|
||||||
import { Truncate } from "./truncate"
|
import { Truncate } from "./truncate"
|
||||||
|
|
||||||
import { ApplyPatchTool } from "./apply_patch"
|
import { ApplyPatchTool } from "./apply_patch"
|
||||||
import { Glob } from "../util/glob"
|
import { Glob } from "../util/glob"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
|
|
||||||
export namespace ToolRegistry {
|
export namespace ToolRegistry {
|
||||||
const log = Log.create({ service: "tool.registry" })
|
const log = Log.create({ service: "tool.registry" })
|
||||||
|
|
||||||
type State = {
|
export const state = Instance.state(async () => {
|
||||||
custom: Tool.Info[]
|
const custom = [] as Tool.Info[]
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
const matches = await Config.directories().then((dirs) =>
|
||||||
readonly register: (tool: Tool.Info) => Effect.Effect<void>
|
dirs.flatMap((dir) =>
|
||||||
readonly ids: () => Effect.Effect<string[]>
|
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
||||||
readonly tools: (
|
),
|
||||||
model: { providerID: ProviderID; modelID: ModelID },
|
)
|
||||||
agent?: Agent.Info,
|
if (matches.length) await Config.waitForDependencies()
|
||||||
) => Effect.Effect<(Awaited<ReturnType<Tool.Info["init"]>> & { id: string })[]>
|
for (const match of matches) {
|
||||||
}
|
const namespace = path.basename(match, path.extname(match))
|
||||||
|
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||||
|
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const cache = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
|
||||||
const custom: Tool.Info[] = []
|
|
||||||
|
|
||||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
init: async (initCtx) => ({
|
|
||||||
parameters: z.object(def.args),
|
|
||||||
description: def.description,
|
|
||||||
execute: async (args, toolCtx) => {
|
|
||||||
const pluginCtx = {
|
|
||||||
...toolCtx,
|
|
||||||
directory: ctx.directory,
|
|
||||||
worktree: ctx.worktree,
|
|
||||||
} as unknown as PluginToolContext
|
|
||||||
const result = await def.execute(args as any, pluginCtx)
|
|
||||||
const out = await Truncate.output(result, {}, initCtx?.agent)
|
|
||||||
return {
|
|
||||||
title: "",
|
|
||||||
output: out.truncated ? out.content : result,
|
|
||||||
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
const matches = await Config.directories().then((dirs) =>
|
|
||||||
dirs.flatMap((dir) =>
|
|
||||||
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (matches.length) await Config.waitForDependencies()
|
|
||||||
for (const match of matches) {
|
|
||||||
const namespace = path.basename(match, path.extname(match))
|
|
||||||
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
|
|
||||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
|
||||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugins = await Plugin.list()
|
|
||||||
for (const plugin of plugins) {
|
|
||||||
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
|
|
||||||
custom.push(fromPlugin(id, def))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return { custom }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function all(custom: Tool.Info[]): Promise<Tool.Info[]> {
|
|
||||||
const cfg = await Config.get()
|
|
||||||
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
|
||||||
|
|
||||||
return [
|
|
||||||
InvalidTool,
|
|
||||||
...(question ? [QuestionTool] : []),
|
|
||||||
BashTool,
|
|
||||||
ReadTool,
|
|
||||||
GlobTool,
|
|
||||||
GrepTool,
|
|
||||||
EditTool,
|
|
||||||
WriteTool,
|
|
||||||
TaskTool,
|
|
||||||
WebFetchTool,
|
|
||||||
TodoWriteTool,
|
|
||||||
WebSearchTool,
|
|
||||||
CodeSearchTool,
|
|
||||||
SkillTool,
|
|
||||||
ApplyPatchTool,
|
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
|
||||||
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
|
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
|
||||||
...custom,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
|
const plugins = await Plugin.list()
|
||||||
const state = yield* InstanceState.get(cache)
|
for (const plugin of plugins) {
|
||||||
const idx = state.custom.findIndex((t) => t.id === tool.id)
|
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
|
||||||
if (idx >= 0) {
|
custom.push(fromPlugin(id, def))
|
||||||
state.custom.splice(idx, 1, tool)
|
}
|
||||||
return
|
}
|
||||||
}
|
|
||||||
state.custom.push(tool)
|
|
||||||
})
|
|
||||||
|
|
||||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
return { custom }
|
||||||
const state = yield* InstanceState.get(cache)
|
})
|
||||||
const tools = yield* Effect.promise(() => all(state.custom))
|
|
||||||
return tools.map((t) => t.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const tools = Effect.fn("ToolRegistry.tools")(function* (
|
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
||||||
model: { providerID: ProviderID; modelID: ModelID },
|
return {
|
||||||
agent?: Agent.Info,
|
id,
|
||||||
) {
|
init: async (initCtx) => ({
|
||||||
const state = yield* InstanceState.get(cache)
|
parameters: z.object(def.args),
|
||||||
const allTools = yield* Effect.promise(() => all(state.custom))
|
description: def.description,
|
||||||
return yield* Effect.promise(() =>
|
execute: async (args, ctx) => {
|
||||||
Promise.all(
|
const pluginCtx = {
|
||||||
allTools
|
...ctx,
|
||||||
.filter((tool) => {
|
directory: Instance.directory,
|
||||||
// Enable websearch/codesearch for zen users OR via enable flag
|
worktree: Instance.worktree,
|
||||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
} as unknown as PluginToolContext
|
||||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
const result = await def.execute(args as any, pluginCtx)
|
||||||
}
|
const out = await Truncate.output(result, {}, initCtx?.agent)
|
||||||
|
return {
|
||||||
// use apply tool in same format as codex
|
title: "",
|
||||||
const usePatch =
|
output: out.truncated ? out.content : result,
|
||||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
||||||
if (tool.id === "apply_patch") return usePatch
|
}
|
||||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
},
|
||||||
|
}),
|
||||||
return true
|
}
|
||||||
})
|
}
|
||||||
.map(async (tool) => {
|
|
||||||
using _ = log.time(tool.id)
|
|
||||||
const next = await tool.init({ agent })
|
|
||||||
const output = {
|
|
||||||
description: next.description,
|
|
||||||
parameters: next.parameters,
|
|
||||||
}
|
|
||||||
await Plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
|
||||||
return {
|
|
||||||
id: tool.id,
|
|
||||||
...next,
|
|
||||||
description: output.description,
|
|
||||||
parameters: output.parameters,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ register, ids, tools })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export async function register(tool: Tool.Info) {
|
export async function register(tool: Tool.Info) {
|
||||||
return runPromise((svc) => svc.register(tool))
|
const { custom } = await state()
|
||||||
|
const idx = custom.findIndex((t) => t.id === tool.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
custom.splice(idx, 1, tool)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
custom.push(tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function all(): Promise<Tool.Info[]> {
|
||||||
|
const custom = await state().then((x) => x.custom)
|
||||||
|
const config = await Config.get()
|
||||||
|
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||||
|
|
||||||
|
return [
|
||||||
|
InvalidTool,
|
||||||
|
...(question ? [QuestionTool] : []),
|
||||||
|
BashTool,
|
||||||
|
ReadTool,
|
||||||
|
GlobTool,
|
||||||
|
GrepTool,
|
||||||
|
EditTool,
|
||||||
|
WriteTool,
|
||||||
|
TaskTool,
|
||||||
|
WebFetchTool,
|
||||||
|
TodoWriteTool,
|
||||||
|
// TodoReadTool,
|
||||||
|
WebSearchTool,
|
||||||
|
CodeSearchTool,
|
||||||
|
SkillTool,
|
||||||
|
ApplyPatchTool,
|
||||||
|
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||||
|
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||||
|
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
||||||
|
...custom,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ids() {
|
export async function ids() {
|
||||||
return runPromise((svc) => svc.ids())
|
return all().then((x) => x.map((t) => t.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tools(
|
export async function tools(
|
||||||
@@ -215,6 +136,39 @@ export namespace ToolRegistry {
|
|||||||
},
|
},
|
||||||
agent?: Agent.Info,
|
agent?: Agent.Info,
|
||||||
) {
|
) {
|
||||||
return runPromise((svc) => svc.tools(model, agent))
|
const tools = await all()
|
||||||
|
const result = await Promise.all(
|
||||||
|
tools
|
||||||
|
.filter((t) => {
|
||||||
|
// Enable websearch/codesearch for zen users OR via enable flag
|
||||||
|
if (t.id === "codesearch" || t.id === "websearch") {
|
||||||
|
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||||
|
}
|
||||||
|
|
||||||
|
// use apply tool in same format as codex
|
||||||
|
const usePatch =
|
||||||
|
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||||
|
if (t.id === "apply_patch") return usePatch
|
||||||
|
if (t.id === "edit" || t.id === "write") return !usePatch
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map(async (t) => {
|
||||||
|
using _ = log.time(t.id)
|
||||||
|
const tool = await t.init({ agent })
|
||||||
|
const output = {
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.parameters,
|
||||||
|
}
|
||||||
|
await Plugin.trigger("tool.definition", { toolID: t.id }, output)
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
...tool,
|
||||||
|
description: output.description,
|
||||||
|
parameters: output.parameters,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,17 +177,13 @@ describeWatcher("FileWatcher", () => {
|
|||||||
await withWatcher(tmp.path, Effect.void)
|
await withWatcher(tmp.path, Effect.void)
|
||||||
|
|
||||||
// Now write a file — no watcher should be listening
|
// Now write a file — no watcher should be listening
|
||||||
await Instance.provide({
|
await Effect.runPromise(
|
||||||
directory: tmp.path,
|
noUpdate(
|
||||||
fn: () =>
|
tmp.path,
|
||||||
Effect.runPromise(
|
(e) => e.file === file,
|
||||||
noUpdate(
|
Effect.promise(() => fs.writeFile(file, "gone")),
|
||||||
tmp.path,
|
),
|
||||||
(e) => e.file === file,
|
)
|
||||||
Effect.promise(() => fs.writeFile(file, "gone")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("ignores .git/index changes", async () => {
|
test("ignores .git/index changes", async () => {
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ describe("tool.read truncation", () => {
|
|||||||
const read = await ReadTool.init()
|
const read = await ReadTool.init()
|
||||||
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
|
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
|
||||||
expect(result.metadata.truncated).toBe(true)
|
expect(result.metadata.truncated).toBe(true)
|
||||||
expect(result.output).toContain("Showing lines 1-10. Use offset=11")
|
expect(result.output).toContain("Showing lines 1-10 of 100")
|
||||||
expect(result.output).toContain("Use offset=11")
|
expect(result.output).toContain("Use offset=11")
|
||||||
expect(result.output).toContain("line0")
|
expect(result.output).toContain("line0")
|
||||||
expect(result.output).toContain("line9")
|
expect(result.output).toContain("line9")
|
||||||
@@ -418,23 +418,6 @@ describe("tool.read truncation", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rejects oversized image attachments", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(path.join(dir, "huge.png"), Buffer.alloc(6 * 1024 * 1024, 0))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const read = await ReadTool.init()
|
|
||||||
await expect(read.execute({ filePath: path.join(tmp.path, "huge.png") }, ctx)).rejects.toThrow(
|
|
||||||
"Cannot attach file larger than 5 MB",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => {
|
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ import type {
|
|||||||
GlobalDisposeResponses,
|
GlobalDisposeResponses,
|
||||||
GlobalEventResponses,
|
GlobalEventResponses,
|
||||||
GlobalHealthResponses,
|
GlobalHealthResponses,
|
||||||
GlobalUpgradeErrors,
|
|
||||||
GlobalUpgradeResponses,
|
|
||||||
InstanceDisposeResponses,
|
InstanceDisposeResponses,
|
||||||
LspStatusResponses,
|
LspStatusResponses,
|
||||||
McpAddErrors,
|
McpAddErrors,
|
||||||
@@ -305,30 +303,6 @@ export class Global extends HeyApiClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Upgrade opencode
|
|
||||||
*
|
|
||||||
* Upgrade opencode to the specified version or latest if not specified.
|
|
||||||
*/
|
|
||||||
public upgrade<ThrowOnError extends boolean = false>(
|
|
||||||
parameters?: {
|
|
||||||
target?: string
|
|
||||||
},
|
|
||||||
options?: Options<never, ThrowOnError>,
|
|
||||||
) {
|
|
||||||
const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }])
|
|
||||||
return (options?.client ?? this.client).post<GlobalUpgradeResponses, GlobalUpgradeErrors, ThrowOnError>({
|
|
||||||
url: "/global/upgrade",
|
|
||||||
...options,
|
|
||||||
...params,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...options?.headers,
|
|
||||||
...params.headers,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private _config?: Config
|
private _config?: Config
|
||||||
get config(): Config {
|
get config(): Config {
|
||||||
return (this._config ??= new Config({ client: this.client }))
|
return (this._config ??= new Config({ client: this.client }))
|
||||||
|
|||||||
@@ -2030,41 +2030,6 @@ export type GlobalDisposeResponses = {
|
|||||||
|
|
||||||
export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]
|
export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]
|
||||||
|
|
||||||
export type GlobalUpgradeData = {
|
|
||||||
body?: {
|
|
||||||
target?: string
|
|
||||||
}
|
|
||||||
path?: never
|
|
||||||
query?: never
|
|
||||||
url: "/global/upgrade"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GlobalUpgradeErrors = {
|
|
||||||
/**
|
|
||||||
* Bad request
|
|
||||||
*/
|
|
||||||
400: BadRequestError
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GlobalUpgradeError = GlobalUpgradeErrors[keyof GlobalUpgradeErrors]
|
|
||||||
|
|
||||||
export type GlobalUpgradeResponses = {
|
|
||||||
/**
|
|
||||||
* Upgrade result
|
|
||||||
*/
|
|
||||||
200:
|
|
||||||
| {
|
|
||||||
success: true
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
success: false
|
|
||||||
error: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses]
|
|
||||||
|
|
||||||
export type AuthRemoveData = {
|
export type AuthRemoveData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -158,82 +158,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/global/upgrade": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "global.upgrade",
|
|
||||||
"summary": "Upgrade opencode",
|
|
||||||
"description": "Upgrade opencode to the specified version or latest if not specified.",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Upgrade result",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"success": {
|
|
||||||
"type": "boolean",
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"version": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["success", "version"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"success": {
|
|
||||||
"type": "boolean",
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["success", "error"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad request",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/BadRequestError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"target": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-codeSamples": [
|
|
||||||
{
|
|
||||||
"lang": "js",
|
|
||||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.upgrade({\n ...\n})"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/{providerID}": {
|
"/auth/{providerID}": {
|
||||||
"put": {
|
"put": {
|
||||||
"operationId": "auth.set",
|
"operationId": "auth.set",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const dict: Record<string, string> = {
|
|||||||
"prompt.placeholder.shell": "Run a shell command...",
|
"prompt.placeholder.shell": "Run a shell command...",
|
||||||
"prompt.placeholder.summarizeComment": "Summarize this comment",
|
"prompt.placeholder.summarizeComment": "Summarize this comment",
|
||||||
"prompt.placeholder.summarizeComments": "Summarize these comments",
|
"prompt.placeholder.summarizeComments": "Summarize these comments",
|
||||||
"prompt.action.attachFile": "Attach files",
|
"prompt.action.attachFile": "Attach file",
|
||||||
"prompt.action.send": "Send",
|
"prompt.action.send": "Send",
|
||||||
"prompt.action.stop": "Stop",
|
"prompt.action.stop": "Stop",
|
||||||
"prompt.attachment.remove": "Remove attachment",
|
"prompt.attachment.remove": "Remove attachment",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -8,10 +8,7 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-trigger-content"] {
|
[data-slot="basic-tool-tool-trigger-content"] {
|
||||||
flex: 0 1 auto;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: calc(100% - 24px);
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
@@ -54,16 +51,12 @@
|
|||||||
[data-slot="basic-tool-tool-info"] {
|
[data-slot="basic-tool-tool-info"] {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-info-structured"] {
|
[data-slot="basic-tool-tool-info-structured"] {
|
||||||
flex: 0 1 auto;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100%;
|
display: flex;
|
||||||
min-width: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -158,10 +151,4 @@
|
|||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
color: var(--text-base);
|
color: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-action"] {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,9 +174,7 @@ export function BasicTool(props: BasicToolProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={!pending() && trigger().action}>
|
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
|
||||||
<span data-slot="basic-tool-tool-action">{trigger().action}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function HoverCard(props: HoverCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Kobalte gutter={4} {...rest}>
|
<Kobalte gutter={4} {...rest}>
|
||||||
<Kobalte.Trigger as="div" data-slot="hover-card-trigger" tabIndex={-1}>
|
<Kobalte.Trigger as="div" data-slot="hover-card-trigger">
|
||||||
{local.trigger}
|
{local.trigger}
|
||||||
</Kobalte.Trigger>
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal mount={local.mount}>
|
<Kobalte.Portal mount={local.mount}>
|
||||||
|
|||||||
@@ -424,7 +424,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
[data-slot="message-part-title-area"] {
|
[data-slot="message-part-title-area"] {
|
||||||
@@ -436,11 +436,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="message-part-title"] {
|
[data-slot="message-part-title"] {
|
||||||
flex: 1 1 auto;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -467,17 +466,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="message-part-title-text"] {
|
[data-slot="message-part-title-text"] {
|
||||||
flex-shrink: 0;
|
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="message-part-title-filename"] {
|
[data-slot="message-part-title-filename"] {
|
||||||
/* No text-transform - preserve original filename casing */
|
/* No text-transform - preserve original filename casing */
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,7 +501,6 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,7 +1183,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="apply-patch-directory"] {
|
[data-slot="apply-patch-directory"] {
|
||||||
@@ -1204,11 +1196,7 @@
|
|||||||
|
|
||||||
[data-slot="apply-patch-filename"] {
|
[data-slot="apply-patch-filename"] {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
flex: 1 1 auto;
|
flex-shrink: 0;
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="apply-patch-trigger-actions"] {
|
[data-slot="apply-patch-trigger-actions"] {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.3.0",
|
"version": "1.2.27",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Reference in New Issue
Block a user