Compare commits

..

10 Commits

Author SHA1 Message Date
Kit Langton 68409c9471 revert unrelated bus type tightening from command PR 2026-03-21 20:11:01 -04:00
Kit Langton d1f7df0b97 refactor(command): initialize state on first access 2026-03-21 20:09:44 -04:00
Kit Langton b1e6ea3e88 refactor(command): track lazy init with fiber 2026-03-21 20:01:17 -04:00
Kit Langton addc33212b docs(command): restore zod async comments 2026-03-21 19:58:38 -04:00
Kit Langton 7053c58d35 fix(command): resolve rebase artifacts 2026-03-21 19:54:09 -04:00
Kit Langton a277fb4049 log errors in catchCause instead of silently swallowing 2026-03-21 14:26:01 -04:00
Kit Langton 59cc1ebc27 use forkScoped + Fiber.join for lazy init (match old Instance.state behavior) 2026-03-21 14:25:57 -04:00
Kit Langton df260fee45 handle undefined command in session prompt 2026-03-21 14:25:50 -04:00
Kit Langton 3b2a1e8415 effectify Command service: migrate from Instance.state to Effect service pattern 2026-03-21 14:25:50 -04:00
Kit Langton 6f9c7812d3 fix(bus): tighten GlobalBus payload and BusEvent.define types
Constrain BusEvent.define to ZodObject instead of ZodType so TS knows
event properties are always a record. Type GlobalBus payload as
{ type: string; properties: Record<string, unknown> } instead of any.

Refactor watcher test to use Bus.subscribe instead of raw GlobalBus
listener, removing hand-rolled event types and unnecessary casts.
2026-03-21 14:24:51 -04:00
80 changed files with 821 additions and 1634 deletions
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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 }}
-5
View File
@@ -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
+32 -33
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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 -10
View File
@@ -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 -2
View File
@@ -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:",
+6 -2
View File
@@ -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)
})
}) })
+1 -1
View File
@@ -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",
+4 -20
View File
@@ -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>
+27 -38
View File
@@ -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} />),
}), }),
-44
View File
@@ -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 -1
View File
@@ -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": {
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -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 -1
View File
@@ -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",
+1 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+6 -6
View File
@@ -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 -1
View File
@@ -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",
View File
+3 -3
View File
@@ -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",
+2 -37
View File
@@ -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 -45
View File
@@ -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),
) )
}) })
} }
+3 -9
View File
@@ -8,18 +8,12 @@ export async function upgrade() {
const method = await Installation.method() const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {}) const latest = await Installation.latest(method).catch(() => {})
if (!latest) return if (!latest) return
if (Installation.VERSION === latest) return
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) { if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return return
} }
if (config.autoupdate === "notify") {
if (Installation.VERSION === latest) return
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const kind = Installation.getReleaseType(Installation.VERSION, latest)
if (config.autoupdate === "notify" || kind !== "patch") {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest }) await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return return
} }
-1
View File
@@ -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(),
+105 -167
View File
@@ -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
View File
@@ -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 -59
View File
@@ -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)
},
), ),
) )
+7 -17
View File
@@ -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(
+3 -3
View File
@@ -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) {
+5 -5
View File
@@ -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))
+28 -50
View File
@@ -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
View File
@@ -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
+4 -8
View File
@@ -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>"
+119 -165
View File
@@ -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
} }
} }
+7 -11
View File
@@ -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 () => {
+1 -18
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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": {
-26
View File
@@ -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 }))
-35
View File
@@ -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: {
-76
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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": {
+1 -14
View File
@@ -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;
}
} }
+1 -3
View File
@@ -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>
+1 -1
View File
@@ -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}>
+3 -15
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",