Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2beeccb39a | |||
| 77b3b46788 | |||
| 36dfe1646b | |||
| 6926dc26d1 | |||
| eb74e4a6d2 | |||
| 85d8e143bf | |||
| 8e1b53b32c | |||
| 0a7dfc03ee | |||
| 4c27e7fc64 | |||
| 0f5626d2e4 | |||
| 5ea95451dd | |||
| 9239d877b9 | |||
| fc68c24433 | |||
| db9619dad6 | |||
| 84d9b38873 | |||
| 8035c3435b | |||
| 71e7603d71 | |||
| 40e49c5b49 | |||
| afe9b97274 | |||
| 3b3549902d | |||
| e9a9c75c1f | |||
| 2b171828b0 | |||
| 8dd817023a | |||
| 0d6c601365 | |||
| 5460bf9989 | |||
| eb3bfffad4 | |||
| e2d03ce38c | |||
| 32f9dc6383 |
+2
-1
@@ -10,6 +10,8 @@
|
||||
adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-atharvau AI review spamming literally every PR
|
||||
-danieljoshuanazareth
|
||||
-danieljoshuanazareth
|
||||
edemaine
|
||||
-florianleibert
|
||||
@@ -23,4 +25,3 @@ r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-danieljoshuanazareth
|
||||
|
||||
@@ -33,6 +33,6 @@ jobs:
|
||||
with:
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
roles: admin,maintain
|
||||
roles: admin,maintain,write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
go through each PR merged since the last tag
|
||||
|
||||
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md
|
||||
|
||||
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -41,6 +41,7 @@
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
"@solid-primitives/websocket": "1.3.1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
@@ -78,7 +79,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -112,7 +113,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -129,7 +130,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.0",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
@@ -139,7 +140,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -163,7 +164,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -187,7 +188,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -220,7 +221,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -251,7 +252,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -280,7 +281,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -296,7 +297,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -337,8 +338,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.88",
|
||||
"@opentui/solid": "0.1.88",
|
||||
"@opentui/core": "0.1.90",
|
||||
"@opentui/solid": "0.1.90",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -420,7 +421,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -444,7 +445,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -455,7 +456,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -490,7 +491,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -536,7 +537,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -547,7 +548,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -611,7 +612,7 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/bun": "1.3.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
@@ -1447,21 +1448,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.88", "", { "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.88", "@opentui/core-darwin-x64": "0.1.88", "@opentui/core-linux-arm64": "0.1.88", "@opentui/core-linux-x64": "0.1.88", "@opentui/core-win32-arm64": "0.1.88", "@opentui/core-win32-x64": "0.1.88", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-eaDVZfAzZraddOIkgWSHMVkyaY0O20foYnPWKPQx1TY4t7G1oatIoan2zkytx67epW+4BZQ9vGib+61/uNM1MA=="],
|
||||
"@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.88", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oGRexWwZFeQJymOK5ORrLrwJUbPHMYaFa0EcLnlhvPnymm1xyMcRKm39ez0WSIdtiCCi/PmMHX95CfyyJB5VMA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.88", "", { "os": "darwin", "cpu": "x64" }, "sha512-ddnruYpXt7gXsAqZoQzNrHtZ50niYQfESVT3rhE5qgsz7zoWBdKe/RxLKcb6zQmHMZML6SjSh0NrMG86lsH4dQ=="],
|
||||
"@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-linux-arm64": ["@opentui/core-linux-arm64@0.1.88", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfcU/Sw8re3aWWb9cQ4OXmVNp/pchu6lgDRqvfy0EKTpzd7CNIu6a0xm+rcUKiPO7BrTrwtumT5/jZWWgCdHlg=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.88", "", { "os": "linux", "cpu": "x64" }, "sha512-nyfilOYLu6XWRlPl1R0Y6WzdL+jVdIFnwShBWcZL+QC5HiJnQc6LKy5yX8uv0fVbY5xs1wBvlHVeUj1UwFQyFQ=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.88", "", { "os": "win32", "cpu": "arm64" }, "sha512-jv/dQwcku7YZ4lNnYjivVvjPwTfDfzGfcplUqHxmirnv1Q1pZL1qS5wH1PV6RhAKN779vHTvnYMD4OgHWzqVaA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.88", "", { "os": "win32", "cpu": "x64" }, "sha512-saGvsQqwL8H7B0VBCQ+szMCKh9WIfTebOR8cwPa2+DR+1FnrEG2I4kiikoj4hfYfRMX18A0A11vQxSh3vvy8Ig=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.88", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.88", "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-hAqMBk3u/MnUapOmRPdMZinXPOFC+5ccmW1rEQRf9HpShRlZfyg9/u+wUI5rUavyeNFtka92Mtjf/N4AKQpwuA=="],
|
||||
"@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=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -1889,6 +1890,8 @@
|
||||
|
||||
"@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/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
|
||||
@@ -2057,7 +2060,7 @@
|
||||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -2453,7 +2456,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-u+uZX7mhtm5eywGybB7/MjBMG2xl4Ve9VG33AAFgNno=",
|
||||
"aarch64-linux": "sha256-pc1Xhd2bkwNohGMtzRnEuS5ZN1qWhJncYhNVAXega1g=",
|
||||
"aarch64-darwin": "sha256-A5qUpqgm9ZFvWVhn/WdiX4lVs4ihbAclJDvCFAmx5Wg=",
|
||||
"x86_64-darwin": "sha256-ECLrMGE51AlYJ4JKDtziDKxhyK7WLt8R+8RVFdXH1WU="
|
||||
"x86_64-linux": "sha256-E5neEbBiwQDhIQ5QVhijpHCCP9hcxm319S9WrDKngSw=",
|
||||
"aarch64-linux": "sha256-lnwaGSEirl9izskDooB/xQ0ZdirW0t3/S+OoOnfYaoQ=",
|
||||
"aarch64-darwin": "sha256-RDxxW9NMlGMIdIxTsbOYVqxunflkILv2dA7JqjnJgm4=",
|
||||
"x86_64-darwin": "sha256-1tvvktu2NRg6N6ASuKzqzcEmMrzH3/LFey0Vxr4E8zg="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.10",
|
||||
"packageManager": "bun@1.3.11",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
@@ -26,7 +26,7 @@
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
@@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
|
||||
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
export async function openPalette(page: Page, key = "K") {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
await page.keyboard.press(`${modKey}+${key}`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openPalette } from "../actions"
|
||||
import { closeDialog, openPalette } from "../actions"
|
||||
|
||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await page.keyboard.press("Escape")
|
||||
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,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await edge(page, "start")
|
||||
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
@@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, draft)
|
||||
await wait(page, "")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("P")
|
||||
expect(initialKeybind).toContain("K")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||
import {
|
||||
defocus,
|
||||
cleanupSession,
|
||||
cleanupTestProject,
|
||||
closeSidebar,
|
||||
createTestProject,
|
||||
hoverSessionItem,
|
||||
openSidebar,
|
||||
waitSession,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
@@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -51,6 +51,7 @@
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
"@solid-primitives/websocket": "1.3.1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
|
||||
@@ -1043,7 +1043,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
@@ -1388,11 +1388,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const list = e.currentTarget.files
|
||||
if (list) {
|
||||
for (const file of Array.from(list)) {
|
||||
void addAttachment(file)
|
||||
}
|
||||
}
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
||||
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 current = prompt.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
@@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const items = Array.from(clipboardData.items)
|
||||
const fileItems = items.filter((item) => item.kind === "file")
|
||||
const files = Array.from(clipboardData.items).flatMap((item) => {
|
||||
if (item.kind !== "file") return []
|
||||
const file = item.getAsFile()
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
if (fileItems.length > 0) {
|
||||
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()
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
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()
|
||||
await addAttachments(Array.from(dropped))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
|
||||
@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
|
||||
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", () => {
|
||||
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", () => {
|
||||
const value = "a\nb\nc"
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||
@@ -135,11 +135,14 @@ describe("prompt-input history", () => {
|
||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "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", 3, 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 atEnd = position === text.length
|
||||
if (inHistory) return atStart || atEnd
|
||||
if (direction === "up") return position === 0
|
||||
if (direction === "up") return position === 0 && text.length === 0
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
|
||||
@@ -267,14 +267,14 @@ export function SessionContextTab() {
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
class="@container h-full pb-10"
|
||||
class="@container h-full"
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
restoreScroll()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div class="px-6 pt-4 flex flex-col gap-10">
|
||||
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
|
||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||
<For each={stats}>
|
||||
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||
|
||||
@@ -40,4 +40,11 @@ describe("command keybind helpers", () => {
|
||||
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
||||
expect(formatKeybind("none")).toBe("")
|
||||
})
|
||||
|
||||
test("formatKeybind prefers the first combo", () => {
|
||||
const display = formatKeybind("mod+k,mod+p")
|
||||
|
||||
expect(display.includes("K") || display.includes("k")).toBe(true)
|
||||
expect(display.includes("P") || display.includes("p")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -276,7 +276,7 @@ export const dict = {
|
||||
"prompt.context.includeActiveFile": "Include active file",
|
||||
"prompt.context.removeActiveFile": "Remove active file from context",
|
||||
"prompt.context.removeFile": "Remove file from context",
|
||||
"prompt.action.attachFile": "Add file",
|
||||
"prompt.action.attachFile": "Add files",
|
||||
"prompt.attachment.remove": "Remove attachment",
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
|
||||
@@ -211,13 +211,22 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
onMount(() => {
|
||||
const stop = () => setState("sizing", false)
|
||||
const blur = () => reset()
|
||||
const hide = () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
reset()
|
||||
}
|
||||
window.addEventListener("pointerup", stop)
|
||||
window.addEventListener("pointercancel", stop)
|
||||
window.addEventListener("blur", stop)
|
||||
window.addEventListener("blur", blur)
|
||||
document.addEventListener("visibilitychange", hide)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pointerup", stop)
|
||||
window.removeEventListener("pointercancel", stop)
|
||||
window.removeEventListener("blur", stop)
|
||||
window.removeEventListener("blur", blur)
|
||||
document.removeEventListener("visibilitychange", hide)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -237,6 +246,12 @@ export default function Layout(props: ParentProps) {
|
||||
navLeave.current = undefined
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
disarm()
|
||||
setState("hoverSession", undefined)
|
||||
setHoverProject(undefined)
|
||||
}
|
||||
|
||||
const arm = () => {
|
||||
if (layout.sidebar.opened()) return
|
||||
if (state.hoverProject === undefined) return
|
||||
@@ -305,8 +320,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const clearSidebarHoverState = () => {
|
||||
if (layout.sidebar.opened()) return
|
||||
setState("hoverSession", undefined)
|
||||
setHoverProject(undefined)
|
||||
reset()
|
||||
}
|
||||
|
||||
const navigateWithSidebarReset = (href: string) => {
|
||||
@@ -1975,6 +1989,10 @@ export default function Layout(props: ParentProps) {
|
||||
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
||||
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
||||
onProjectFocus: (worktree) => aim.activate(worktree),
|
||||
onHoverOpenChanged: (worktree, hoverOpen) => {
|
||||
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
|
||||
setState("hoverProject", hoverOpen ? worktree : undefined)
|
||||
},
|
||||
navigateToProject,
|
||||
openSidebar: () => layout.sidebar.open(),
|
||||
closeProject,
|
||||
@@ -2368,14 +2386,12 @@ export default function Layout(props: ParentProps) {
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -157,34 +157,45 @@ const SessionHoverPreview = (props: {
|
||||
messageLabel: (message: Message) => string | undefined
|
||||
onMessageSelect: (message: Message) => void
|
||||
trigger: JSX.Element
|
||||
}): JSX.Element => (
|
||||
<HoverCard
|
||||
openDelay={1000}
|
||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
||||
placement="right-start"
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={props.trigger}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
||||
>
|
||||
<Show
|
||||
when={props.hoverReady()}
|
||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||
}): JSX.Element => {
|
||||
let ref: HTMLDivElement | undefined
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
openDelay={1000}
|
||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
||||
placement="right-start"
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={<div ref={ref}>{props.trigger}</div>}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
props.setHoverSession(undefined)
|
||||
return
|
||||
}
|
||||
if (!ref?.matches(":hover")) return
|
||||
props.setHoverSession(props.session.id)
|
||||
}}
|
||||
>
|
||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
||||
<MessageNav
|
||||
messages={props.hoverMessages() ?? []}
|
||||
current={undefined}
|
||||
getLabel={props.messageLabel}
|
||||
onMessageSelect={props.onMessageSelect}
|
||||
size="normal"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</HoverCard>
|
||||
)
|
||||
<Show
|
||||
when={props.hoverReady()}
|
||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||
>
|
||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
||||
<MessageNav
|
||||
messages={props.hoverMessages() ?? []}
|
||||
current={undefined}
|
||||
getLabel={props.messageLabel}
|
||||
onMessageSelect={props.onMessageSelect}
|
||||
size="normal"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const params = useParams()
|
||||
|
||||
@@ -23,6 +23,7 @@ export type ProjectSidebarContext = {
|
||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||
onProjectMouseLeave: (worktree: string) => void
|
||||
onProjectFocus: (worktree: string) => void
|
||||
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
|
||||
navigateToProject: (directory: string) => void
|
||||
openSidebar: () => void
|
||||
closeProject: (directory: string) => void
|
||||
@@ -109,8 +110,14 @@ const ProjectTile = (props: {
|
||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button === 0 && !event.ctrlKey) {
|
||||
props.setOpen(false)
|
||||
props.setSuppressHover(true)
|
||||
return
|
||||
}
|
||||
if (!props.overlay()) return
|
||||
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
||||
props.setOpen(false)
|
||||
props.setSuppressHover(true)
|
||||
event.preventDefault()
|
||||
}}
|
||||
@@ -130,12 +137,11 @@ const ProjectTile = (props: {
|
||||
props.onProjectFocus(props.project.worktree)
|
||||
}}
|
||||
onClick={() => {
|
||||
props.setOpen(false)
|
||||
if (props.selected()) {
|
||||
props.setSuppressHover(true)
|
||||
layout.sidebar.toggle()
|
||||
return
|
||||
}
|
||||
props.setSuppressHover(false)
|
||||
props.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
onBlur={() => props.setOpen(false)}
|
||||
@@ -192,7 +198,6 @@ const ProjectPreviewPanel = (props: {
|
||||
projectChildren: Accessor<Map<string, string[]>>
|
||||
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
||||
workspaceChildren: (directory: string) => Map<string, string[]>
|
||||
setOpen: (value: boolean) => void
|
||||
ctx: ProjectSidebarContext
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => (
|
||||
@@ -259,7 +264,7 @@ const ProjectPreviewPanel = (props: {
|
||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
props.ctx.openSidebar()
|
||||
props.setOpen(false)
|
||||
props.ctx.onHoverOpenChanged(props.project.worktree, false)
|
||||
if (props.selected()) return
|
||||
props.ctx.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
@@ -284,28 +289,16 @@ export const SortableProject = (props: {
|
||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
menu: false,
|
||||
suppressHover: false,
|
||||
})
|
||||
|
||||
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
|
||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||
const active = createMemo(
|
||||
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
|
||||
)
|
||||
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
|
||||
|
||||
createEffect(() => {
|
||||
if (preview()) return
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!selected()) return
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
||||
|
||||
const label = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
@@ -346,7 +339,7 @@ export const SortableProject = (props: {
|
||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||
closeProject={props.ctx.closeProject}
|
||||
setMenu={(value) => setState("menu", value)}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
|
||||
setSuppressHover={(value) => setState("suppressHover", value)}
|
||||
language={language}
|
||||
/>
|
||||
@@ -357,7 +350,7 @@ export const SortableProject = (props: {
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<Show when={preview() && !selected()} fallback={tile()}>
|
||||
<HoverCard
|
||||
open={!state.suppressHover && state.open && !state.menu}
|
||||
open={!state.suppressHover && hoverOpen() && !state.menu}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
placement="right-start"
|
||||
@@ -366,7 +359,7 @@ export const SortableProject = (props: {
|
||||
onOpenChange={(value) => {
|
||||
if (state.menu) return
|
||||
if (value && state.suppressHover) return
|
||||
setState("open", value)
|
||||
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
||||
if (value) props.ctx.setHoverSession(undefined)
|
||||
}}
|
||||
>
|
||||
@@ -381,7 +374,6 @@ export const SortableProject = (props: {
|
||||
projectChildren={projectChildren}
|
||||
workspaceSessions={workspaceSessions}
|
||||
workspaceChildren={workspaceChildren}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
ctx={props.ctx}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
@@ -30,6 +30,7 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { makeTimer } from "@solid-primitives/timer"
|
||||
|
||||
type MessageComment = {
|
||||
path: string
|
||||
@@ -250,38 +251,21 @@ export function MessageTimeline(props: {
|
||||
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
|
||||
|
||||
const [slot, setSlot] = createStore({
|
||||
open: false,
|
||||
show: false,
|
||||
fade: false,
|
||||
const [timeoutDone, setTimeoutDone] = createSignal(true)
|
||||
|
||||
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
|
||||
if (working()) return "showing"
|
||||
if (prev === "showing" || !timeoutDone()) return "hiding"
|
||||
return "hidden"
|
||||
})
|
||||
|
||||
let f: number | undefined
|
||||
const clear = () => {
|
||||
if (f !== undefined) window.clearTimeout(f)
|
||||
f = undefined
|
||||
}
|
||||
createEffect(() => {
|
||||
if (workingStatus() !== "hiding") return
|
||||
|
||||
setTimeoutDone(false)
|
||||
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 parentID = pending()?.parentID
|
||||
if (parentID) {
|
||||
@@ -676,17 +660,15 @@ export function MessageTimeline(props: {
|
||||
<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)]"
|
||||
style={{
|
||||
width: slot.open ? "16px" : "0px",
|
||||
"margin-right": slot.open ? "8px" : "0px",
|
||||
width: working() ? "16px" : "0px",
|
||||
"margin-right": working() ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show when={slot.show}>
|
||||
<Show when={workingStatus() !== "hidden"}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-0": slot.fade,
|
||||
}}
|
||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
@@ -912,7 +894,6 @@ export function MessageTimeline(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
|
||||
@@ -438,12 +438,10 @@ export function SessionSidePanel(props: {
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
collapseThreshold={160}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
onCollapse={layout.fileTree.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+p",
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Part } from "@opencode-ai/sdk/v2"
|
||||
import { extractPromptFromParts } from "./prompt"
|
||||
|
||||
describe("extractPromptFromParts", () => {
|
||||
test("restores multiple uploaded attachments", () => {
|
||||
const parts = [
|
||||
{
|
||||
id: "text_1",
|
||||
type: "text",
|
||||
text: "check these",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
{
|
||||
id: "file_1",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
url: "data:image/png;base64,AAA",
|
||||
filename: "a.png",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
{
|
||||
id: "file_2",
|
||||
type: "file",
|
||||
mime: "application/pdf",
|
||||
url: "data:application/pdf;base64,BBB",
|
||||
filename: "b.pdf",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
] satisfies Part[]
|
||||
|
||||
const result = extractPromptFromParts(parts)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
|
||||
expect(result.slice(1)).toMatchObject([
|
||||
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -42,7 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.0",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"mysql2": "3.14.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.3.5
|
||||
ARG BUN_VERSION=1.3.11
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -35,6 +35,7 @@ export type CommandEvent =
|
||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
||||
|
||||
export type CommandChild = {
|
||||
pid: number | undefined
|
||||
kill: () => void
|
||||
}
|
||||
|
||||
@@ -191,7 +192,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
||||
treeKill(child.pid)
|
||||
}
|
||||
|
||||
return { events, child: { kill }, exit }
|
||||
return { events, child: { pid: child.pid, kill }, exit }
|
||||
}
|
||||
|
||||
function handleSqliteProgress(events: EventEmitter, line: string) {
|
||||
|
||||
@@ -81,6 +81,17 @@ function setupApp() {
|
||||
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 () => {
|
||||
// migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
@@ -234,8 +245,15 @@ registerIpcHandlers({
|
||||
|
||||
function killSidecar() {
|
||||
if (!sidecar) return
|
||||
const pid = sidecar.pid
|
||||
sidecar.kill()
|
||||
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() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.27"
|
||||
version = "1.3.0"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -101,8 +101,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.88",
|
||||
"@opentui/solid": "0.1.88",
|
||||
"@opentui/core": "0.1.90",
|
||||
"@opentui/solid": "0.1.90",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -5,8 +5,8 @@ import { MouseButton, TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Installation } from "@/installation"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import semver from "semver"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
@@ -29,6 +29,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { Session as SessionApi } from "@/session"
|
||||
@@ -103,6 +104,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
}
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
@@ -729,13 +731,51 @@ function App() {
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
|
||||
sdk.event.on("installation.update-available", async (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({
|
||||
variant: "info",
|
||||
title: "Update Available",
|
||||
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
|
||||
duration: 10000,
|
||||
message: `Updating to v${version}...`,
|
||||
duration: 30000,
|
||||
})
|
||||
|
||||
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 (
|
||||
|
||||
@@ -428,7 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
||||
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const transparent = RGBA.fromInts(0, 0, 0, 0)
|
||||
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
|
||||
const isDark = mode == "dark"
|
||||
|
||||
const col = (i: number) => {
|
||||
|
||||
@@ -1465,6 +1465,8 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||
streaming={true}
|
||||
content={props.part.text.trim()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||
|
||||
@@ -11,8 +11,11 @@ export type DialogConfirmProps = {
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type DialogConfirmResult = boolean | undefined
|
||||
|
||||
export function DialogConfirm(props: DialogConfirmProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
@@ -45,7 +48,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
||||
<text fg={theme.textMuted}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["cancel", "confirm"]}>
|
||||
<For each={["cancel", "confirm"] as const}>
|
||||
{(key) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
@@ -58,7 +61,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
||||
}}
|
||||
>
|
||||
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
|
||||
{Locale.titlecase(key)}
|
||||
{Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
@@ -68,8 +71,8 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
||||
)
|
||||
}
|
||||
|
||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
|
||||
return new Promise<DialogConfirmResult>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogConfirm
|
||||
@@ -77,9 +80,10 @@ DialogConfirm.show = (dialog: DialogContext, title: string, message: string) =>
|
||||
message={message}
|
||||
onConfirm={() => resolve(true)}
|
||||
onCancel={() => resolve(false)}
|
||||
label={label}
|
||||
/>
|
||||
),
|
||||
() => resolve(false),
|
||||
() => resolve(undefined),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,12 +8,18 @@ export async function upgrade() {
|
||||
const method = await Installation.method()
|
||||
const latest = await Installation.latest(method).catch(() => {})
|
||||
if (!latest) return
|
||||
if (Installation.VERSION === latest) return
|
||||
|
||||
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) {
|
||||
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
|
||||
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
|
||||
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 })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export namespace Flag {
|
||||
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
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_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
|
||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||
|
||||
@@ -15,11 +15,15 @@ declare global {
|
||||
const OPENCODE_CHANNEL: string
|
||||
}
|
||||
|
||||
import semver from "semver"
|
||||
|
||||
export namespace Installation {
|
||||
const log = Log.create({ service: "installation" })
|
||||
|
||||
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
|
||||
|
||||
export type ReleaseType = "patch" | "minor" | "major"
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"installation.updated",
|
||||
@@ -35,6 +39,17 @@ 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
|
||||
.object({
|
||||
version: z.string(),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import z from "zod"
|
||||
import { Bus } from "../../bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
@@ -195,5 +196,62 @@ export const GlobalRoutes = lazy(() =>
|
||||
})
|
||||
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)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
+109
-101
@@ -33,6 +33,16 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
|
||||
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", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
@@ -115,23 +125,16 @@ export const EditTool = Tool.define("edit", {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
contentNew = await Filesystem.readText(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
})
|
||||
|
||||
const count = stats(contentOld, contentNew)
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file: filePath,
|
||||
before: contentOld,
|
||||
after: contentNew,
|
||||
additions: 0,
|
||||
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
|
||||
additions: count.additions,
|
||||
deletions: count.deletions,
|
||||
}
|
||||
|
||||
ctx.metadata({
|
||||
@@ -167,7 +170,18 @@ export const EditTool = Tool.define("edit", {
|
||||
},
|
||||
})
|
||||
|
||||
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
|
||||
type Prep = {
|
||||
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
|
||||
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
||||
@@ -181,37 +195,36 @@ function levenshtein(a: string, b: string): number {
|
||||
if (a === "" || b === "") {
|
||||
return Math.max(a.length, b.length)
|
||||
}
|
||||
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
||||
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
||||
)
|
||||
const [left, right] = a.length < b.length ? [a, b] : [b, a]
|
||||
let prev = Array.from({ length: left.length + 1 }, (_, i) => i)
|
||||
let next = Array.from({ length: left.length + 1 }, () => 0)
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[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)
|
||||
for (let i = 1; i <= right.length; i++) {
|
||||
next[0] = i
|
||||
for (let j = 1; j <= left.length; j++) {
|
||||
const cost = right[i - 1] === left[j - 1] ? 0 : 1
|
||||
next[j] = Math.min(next[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
|
||||
}
|
||||
}
|
||||
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()
|
||||
;[prev, next] = [next, prev]
|
||||
}
|
||||
|
||||
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
||||
return prev[left.length]
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
for (let j = 0; j < search.length; j++) {
|
||||
const originalTrimmed = original[i + j].trim()
|
||||
const searchTrimmed = search[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
@@ -222,48 +235,42 @@ export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
||||
if (matches) {
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
matchStartIndex += original[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length
|
||||
if (k < searchLines.length - 1) {
|
||||
for (let k = 0; k < search.length; k++) {
|
||||
matchEndIndex += original[i + k].length
|
||||
if (k < search.length - 1) {
|
||||
matchEndIndex += 1 // Add newline character except for the last line
|
||||
}
|
||||
}
|
||||
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
yield prep.content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
const originalLines = content.split("\n")
|
||||
const searchLines = find.split("\n")
|
||||
export const BlockAnchorReplacer: Replacer = function* (prep) {
|
||||
const original = prep.lines
|
||||
const search = trimLastEmpty(prep.finds)
|
||||
|
||||
if (searchLines.length < 3) {
|
||||
return
|
||||
}
|
||||
if (search.length < 3) return
|
||||
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
const firstLineSearch = search[0].trim()
|
||||
const lastLineSearch = search[search.length - 1].trim()
|
||||
const searchBlockSize = search.length
|
||||
|
||||
// Collect all candidate positions where both anchors match
|
||||
const candidates: Array<{ startLine: number; endLine: number }> = []
|
||||
for (let i = 0; i < originalLines.length; i++) {
|
||||
if (originalLines[i].trim() !== firstLineSearch) {
|
||||
for (let i = 0; i < original.length; i++) {
|
||||
if (original[i].trim() !== firstLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Look for the matching last line after this first line
|
||||
for (let j = i + 2; j < originalLines.length; j++) {
|
||||
if (originalLines[j].trim() === lastLineSearch) {
|
||||
for (let j = i + 2; j < original.length; j++) {
|
||||
if (original[j].trim() === lastLineSearch) {
|
||||
candidates.push({ startLine: i, endLine: j })
|
||||
break // Only match the first occurrence of the last line
|
||||
}
|
||||
@@ -285,8 +292,8 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
|
||||
if (linesToCheck > 0) {
|
||||
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[startLine + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const originalLine = original[startLine + j].trim()
|
||||
const searchLine = search[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
@@ -307,16 +314,16 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < startLine; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
matchStartIndex += original[k].length + 1
|
||||
}
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = startLine; k <= endLine; k++) {
|
||||
matchEndIndex += originalLines[k].length
|
||||
matchEndIndex += original[k].length
|
||||
if (k < endLine) {
|
||||
matchEndIndex += 1 // Add newline character except for the last line
|
||||
}
|
||||
}
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
yield prep.content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -334,8 +341,8 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
|
||||
if (linesToCheck > 0) {
|
||||
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[startLine + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const originalLine = original[startLine + j].trim()
|
||||
const searchLine = search[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
@@ -360,25 +367,25 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
const { startLine, endLine } = bestMatch
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < startLine; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
matchStartIndex += original[k].length + 1
|
||||
}
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = startLine; k <= endLine; k++) {
|
||||
matchEndIndex += originalLines[k].length
|
||||
matchEndIndex += original[k].length
|
||||
if (k < endLine) {
|
||||
matchEndIndex += 1
|
||||
}
|
||||
}
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
yield prep.content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
}
|
||||
|
||||
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
||||
export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
|
||||
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
||||
const normalizedFind = normalizeWhitespace(find)
|
||||
const normalizedFind = normalizeWhitespace(prep.find)
|
||||
|
||||
// Handle single line matches
|
||||
const lines = content.split("\n")
|
||||
const lines = prep.lines
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (normalizeWhitespace(line) === normalizedFind) {
|
||||
@@ -388,7 +395,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
|
||||
const normalizedLine = normalizeWhitespace(line)
|
||||
if (normalizedLine.includes(normalizedFind)) {
|
||||
// Find the actual substring in the original line that matches
|
||||
const words = find.trim().split(/\s+/)
|
||||
const words = prep.find.trim().split(/\s+/)
|
||||
if (words.length > 0) {
|
||||
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
|
||||
try {
|
||||
@@ -406,7 +413,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
|
||||
}
|
||||
|
||||
// Handle multi-line matches
|
||||
const findLines = find.split("\n")
|
||||
const findLines = prep.finds
|
||||
if (findLines.length > 1) {
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
const block = lines.slice(i, i + findLines.length)
|
||||
@@ -417,7 +424,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
|
||||
}
|
||||
}
|
||||
|
||||
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||
export const IndentationFlexibleReplacer: Replacer = function* (prep) {
|
||||
const removeIndentation = (text: string) => {
|
||||
const lines = text.split("\n")
|
||||
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
||||
@@ -433,9 +440,9 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
|
||||
}
|
||||
|
||||
const normalizedFind = removeIndentation(find)
|
||||
const contentLines = content.split("\n")
|
||||
const findLines = find.split("\n")
|
||||
const normalizedFind = removeIndentation(prep.find)
|
||||
const contentLines = prep.lines
|
||||
const findLines = prep.finds
|
||||
|
||||
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
||||
const block = contentLines.slice(i, i + findLines.length).join("\n")
|
||||
@@ -445,7 +452,7 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||
}
|
||||
}
|
||||
|
||||
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
||||
export const EscapeNormalizedReplacer: Replacer = function* (prep) {
|
||||
const unescapeString = (str: string): string => {
|
||||
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
|
||||
switch (capturedChar) {
|
||||
@@ -473,15 +480,15 @@ export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
||||
})
|
||||
}
|
||||
|
||||
const unescapedFind = unescapeString(find)
|
||||
const unescapedFind = unescapeString(prep.find)
|
||||
|
||||
// Try direct match with unescaped find string
|
||||
if (content.includes(unescapedFind)) {
|
||||
if (prep.content.includes(unescapedFind)) {
|
||||
yield unescapedFind
|
||||
}
|
||||
|
||||
// Also try finding escaped versions in content that match unescaped find
|
||||
const lines = content.split("\n")
|
||||
const lines = prep.lines
|
||||
const findLines = unescapedFind.split("\n")
|
||||
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
@@ -494,36 +501,36 @@ export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
||||
}
|
||||
}
|
||||
|
||||
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
||||
export const MultiOccurrenceReplacer: Replacer = function* (prep) {
|
||||
// This replacer yields all exact matches, allowing the replace function
|
||||
// to handle multiple occurrences based on replaceAll parameter
|
||||
let startIndex = 0
|
||||
|
||||
while (true) {
|
||||
const index = content.indexOf(find, startIndex)
|
||||
const index = prep.content.indexOf(prep.find, startIndex)
|
||||
if (index === -1) break
|
||||
|
||||
yield find
|
||||
startIndex = index + find.length
|
||||
yield prep.find
|
||||
startIndex = index + prep.find.length
|
||||
}
|
||||
}
|
||||
|
||||
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
||||
const trimmedFind = find.trim()
|
||||
export const TrimmedBoundaryReplacer: Replacer = function* (prep) {
|
||||
const trimmedFind = prep.find.trim()
|
||||
|
||||
if (trimmedFind === find) {
|
||||
if (trimmedFind === prep.find) {
|
||||
// Already trimmed, no point in trying
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find the trimmed version
|
||||
if (content.includes(trimmedFind)) {
|
||||
if (prep.content.includes(trimmedFind)) {
|
||||
yield trimmedFind
|
||||
}
|
||||
|
||||
// Also try finding blocks where trimmed content matches
|
||||
const lines = content.split("\n")
|
||||
const findLines = find.split("\n")
|
||||
const lines = prep.lines
|
||||
const findLines = prep.finds
|
||||
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
const block = lines.slice(i, i + findLines.length).join("\n")
|
||||
@@ -534,19 +541,13 @@ export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
||||
}
|
||||
}
|
||||
|
||||
export const ContextAwareReplacer: Replacer = function* (content, find) {
|
||||
const findLines = find.split("\n")
|
||||
export const ContextAwareReplacer: Replacer = function* (prep) {
|
||||
const findLines = trimLastEmpty(prep.finds)
|
||||
if (findLines.length < 3) {
|
||||
// Need at least 3 lines to have meaningful context
|
||||
return
|
||||
}
|
||||
|
||||
// Remove trailing empty line if present
|
||||
if (findLines[findLines.length - 1] === "") {
|
||||
findLines.pop()
|
||||
}
|
||||
|
||||
const contentLines = content.split("\n")
|
||||
const contentLines = prep.lines
|
||||
|
||||
// Extract first and last lines as context anchors
|
||||
const firstLine = findLines[0].trim()
|
||||
@@ -635,6 +636,13 @@ export function replace(content: string, oldString: string, newString: string, r
|
||||
|
||||
let notFound = true
|
||||
|
||||
const prep = {
|
||||
content,
|
||||
find: oldString,
|
||||
lines: content.split("\n"),
|
||||
finds: oldString.split("\n"),
|
||||
} satisfies Prep
|
||||
|
||||
for (const replacer of [
|
||||
SimpleReplacer,
|
||||
LineTrimmedReplacer,
|
||||
@@ -646,7 +654,7 @@ export function replace(content: string, oldString: string, newString: string, r
|
||||
ContextAwareReplacer,
|
||||
MultiOccurrenceReplacer,
|
||||
]) {
|
||||
for (const search of replacer(content, oldString)) {
|
||||
for (const search of replacer(prep)) {
|
||||
const index = content.indexOf(search)
|
||||
if (index === -1) continue
|
||||
notFound = false
|
||||
|
||||
@@ -17,6 +17,8 @@ const MAX_LINE_LENGTH = 2000
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
const MAX_BYTES = 50 * 1024
|
||||
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", {
|
||||
description: DESCRIPTION,
|
||||
@@ -122,6 +124,9 @@ export const ReadTool = Tool.define("read", {
|
||||
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
||||
const isPdf = mime === "application/pdf"
|
||||
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`
|
||||
return {
|
||||
title,
|
||||
@@ -167,7 +172,7 @@ export const ReadTool = Tool.define("read", {
|
||||
|
||||
if (raw.length >= limit) {
|
||||
hasMoreLines = true
|
||||
continue
|
||||
break
|
||||
}
|
||||
|
||||
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
||||
@@ -198,7 +203,6 @@ export const ReadTool = Tool.define("read", {
|
||||
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
||||
output += content.join("\n")
|
||||
|
||||
const totalLines = lines
|
||||
const lastReadLine = offset + raw.length - 1
|
||||
const nextOffset = lastReadLine + 1
|
||||
const truncated = hasMoreLines || truncatedByBytes
|
||||
@@ -206,9 +210,9 @@ export const ReadTool = Tool.define("read", {
|
||||
if (truncatedByBytes) {
|
||||
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
|
||||
} else if (hasMoreLines) {
|
||||
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
|
||||
output += `\n\n(Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
|
||||
} else {
|
||||
output += `\n\n(End of file - total ${totalLines} lines)`
|
||||
output += `\n\n(End of file - total ${lines} lines)`
|
||||
}
|
||||
output += "\n</content>"
|
||||
|
||||
|
||||
@@ -177,13 +177,17 @@ describeWatcher("FileWatcher", () => {
|
||||
await withWatcher(tmp.path, Effect.void)
|
||||
|
||||
// Now write a file — no watcher should be listening
|
||||
await Effect.runPromise(
|
||||
noUpdate(
|
||||
tmp.path,
|
||||
(e) => e.file === file,
|
||||
Effect.promise(() => fs.writeFile(file, "gone")),
|
||||
),
|
||||
)
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
noUpdate(
|
||||
tmp.path,
|
||||
(e) => e.file === file,
|
||||
Effect.promise(() => fs.writeFile(file, "gone")),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores .git/index changes", async () => {
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("tool.read truncation", () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
expect(result.output).toContain("Showing lines 1-10 of 100")
|
||||
expect(result.output).toContain("Showing lines 1-10. Use offset=11")
|
||||
expect(result.output).toContain("Use offset=11")
|
||||
expect(result.output).toContain("line0")
|
||||
expect(result.output).toContain("line9")
|
||||
@@ -418,6 +418,23 @@ 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 () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -46,6 +46,8 @@ import type {
|
||||
GlobalDisposeResponses,
|
||||
GlobalEventResponses,
|
||||
GlobalHealthResponses,
|
||||
GlobalUpgradeErrors,
|
||||
GlobalUpgradeResponses,
|
||||
InstanceDisposeResponses,
|
||||
LspStatusResponses,
|
||||
McpAddErrors,
|
||||
@@ -303,6 +305,30 @@ 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
|
||||
get config(): Config {
|
||||
return (this._config ??= new Config({ client: this.client }))
|
||||
|
||||
@@ -2030,6 +2030,41 @@ export type 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 = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@@ -158,6 +158,82 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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}": {
|
||||
"put": {
|
||||
"operationId": "auth.set",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -8,7 +8,7 @@ const dict: Record<string, string> = {
|
||||
"prompt.placeholder.shell": "Run a shell command...",
|
||||
"prompt.placeholder.summarizeComment": "Summarize this comment",
|
||||
"prompt.placeholder.summarizeComments": "Summarize these comments",
|
||||
"prompt.action.attachFile": "Attach file",
|
||||
"prompt.action.attachFile": "Attach files",
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
"prompt.attachment.remove": "Remove attachment",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
justify-content: flex-start;
|
||||
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
max-width: calc(100% - 24px);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
@@ -51,12 +54,16 @@
|
||||
[data-slot="basic-tool-tool-info"] {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-structured"] {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
@@ -151,4 +158,10 @@
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-action"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,9 @@ export function BasicTool(props: BasicToolProps) {
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
|
||||
<Show when={!pending() && trigger().action}>
|
||||
<span data-slot="basic-tool-tool-action">{trigger().action}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function HoverCard(props: HoverCardProps) {
|
||||
|
||||
return (
|
||||
<Kobalte gutter={4} {...rest}>
|
||||
<Kobalte.Trigger as="div" data-slot="hover-card-trigger">
|
||||
<Kobalte.Trigger as="div" data-slot="hover-card-trigger" tabIndex={-1}>
|
||||
{local.trigger}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal mount={local.mount}>
|
||||
|
||||
@@ -424,7 +424,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
[data-slot="message-part-title-area"] {
|
||||
@@ -436,10 +436,11 @@
|
||||
}
|
||||
|
||||
[data-slot="message-part-title"] {
|
||||
flex-shrink: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
@@ -466,12 +467,17 @@
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-text"] {
|
||||
flex-shrink: 0;
|
||||
text-transform: capitalize;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-filename"] {
|
||||
/* No text-transform - preserve original filename casing */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
@@ -501,6 +507,7 @@
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1183,6 +1190,7 @@
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="apply-patch-directory"] {
|
||||
@@ -1196,7 +1204,11 @@
|
||||
|
||||
[data-slot="apply-patch-filename"] {
|
||||
color: var(--text-strong);
|
||||
flex-shrink: 0;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="apply-patch-trigger-actions"] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.0",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user