Compare commits

..

47 Commits

Author SHA1 Message Date
opencode 20963c4186 release: v0.15.18 2025-10-26 03:49:21 +00:00
Aiden Cline 0a778a2789 make title gen more reliable 2025-10-25 22:14:29 -05:00
Aiden Cline 42c1e61bf4 fix: $ invocation not .quiet() (#3449) 2025-10-25 16:27:09 -05:00
Dax Raad 795b845782 update anthropic prompt 2025-10-25 17:26:27 -04:00
Mohammad Alhashemi 2e434a459a feat: add noReply parameter (#3433)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-10-25 14:56:54 -05:00
Paulo Edgar Castro ae62bc8b1f fix: timeout param that allows user to disable provider timeout (#3443) 2025-10-25 12:08:27 -05:00
GitHub Action 187a5fe301 ignore: update download stats 2025-10-25 2025-10-25 12:04:18 +00:00
opencode fc2afdc92f release: v0.15.17 2025-10-25 06:45:23 +00:00
Aiden Cline fe5e7cfd1b ignore: rm change 2025-10-25 01:35:43 -05:00
Aiden Cline 98d51dde6a acp: slash commands, agents, permissions, @ references, code cleanup (#3403)
Co-authored-by: yetone <yetoneful@gmail.com>
2025-10-25 01:32:46 -05:00
Aiden Cline 5fec5ff424 fix: bedrock reasoning 2025-10-24 17:35:08 -05:00
Adam fea6a357bc wip: desktop work 2025-10-24 16:04:44 -05:00
Adam 6b82153263 wip: desktop work 2025-10-24 15:51:31 -05:00
Adam fa8e714d69 wip: desktop work 2025-10-24 15:43:47 -05:00
Adam 90515bc8c3 wip: desktop work 2025-10-24 15:02:31 -05:00
Adam e34042e17a wip: accordion css not going to keep me down 2025-10-24 15:01:28 -05:00
Dax Raad 6ff0ce8bc5 ignore: improve session timeline debugging and message display functionality 2025-10-24 15:45:37 -04:00
Aiden Cline e88b659545 make plan agent whitelist more conservative (#3424) 2025-10-24 14:40:36 -05:00
Dax Raad 74048ece2d ignore: fix new session message loading with retry logic to handle server processing delays 2025-10-24 15:35:53 -04:00
Dax Raad 6646f7264a ignore: highlight active session in sidebar to improve visual feedback 2025-10-24 15:27:20 -04:00
Dax Raad 18e549a474 ignore: fix session activation after creation to ensure proper state management 2025-10-24 15:24:17 -04:00
Adam 82249754e7 fix: pierre dep 2025-10-24 13:51:24 -05:00
Aiden Cline 5a0228897b ignore: reword 2025-10-24 13:46:47 -05:00
Adam e2920c06a3 wip: desktop work 2025-10-24 13:01:22 -05:00
Dax Raad 4da3aa2eb2 add missing dep 2025-10-24 13:43:33 -04:00
Adam efe7f01f41 wip: desktop work 2025-10-24 12:38:00 -05:00
Adam 9ae3d74adc wip: desktop work 2025-10-24 12:32:02 -05:00
Adam 477b6c584d wip: desktop work 2025-10-24 12:16:33 -05:00
Adam 86447b5764 wip: desktop work 2025-10-24 12:16:33 -05:00
Adam fe8f6d7a3e wip: desktop work 2025-10-24 12:16:33 -05:00
Adam 59b5f53509 wip: desktop work 2025-10-24 12:16:32 -05:00
Adam 3eb2db98ed wip: desktop work 2025-10-24 12:16:32 -05:00
Adam 35dec0649d wip: desktop work 2025-10-24 12:16:32 -05:00
Adam 78a7f79143 wip: ui package demo page 2025-10-24 12:16:32 -05:00
Aiden Cline 707ed72381 adjust edit tool multiple matches error wording (#3418) 2025-10-24 12:14:49 -05:00
Dax Raad 21880e199d mroe summary tweaks 2025-10-24 12:37:23 -04:00
Dax Raad 736a85d427 track finish reason 2025-10-24 12:23:24 -04:00
Dax Raad fb40dc6b20 generate user message title and body 2025-10-24 11:50:42 -04:00
Hieu Nguyen 483fcdaddb feat: support lua lsp (#3402) 2025-10-24 10:37:11 -05:00
Andrew Pashynnyk 883b71ac36 fix: respect local config for autoupdate settings (#3408)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-24 09:50:09 -05:00
Dax Raad 3e574c71cb potentially fix fetch failed timeout errors 2025-10-24 09:46:28 -04:00
Dax Raad 4cab66da6c test cleanup 2025-10-24 09:29:38 -04:00
Dax Raad 7003efd2da fix summary 2025-10-24 09:24:58 -04:00
Dax Raad 06fe87b361 fix failing migration 2025-10-24 09:20:15 -04:00
GitHub Action 944fda45e6 ignore: update download stats 2025-10-24 2025-10-24 12:05:02 +00:00
opencode 343471b98d release: v0.15.16 2025-10-24 04:52:27 +00:00
Dax Raad 56528493dc codex does not have reasoning effort set 2025-10-24 00:29:34 -04:00
76 changed files with 3811 additions and 2066 deletions
+2
View File
@@ -117,3 +117,5 @@
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
+21 -20
View File
@@ -37,7 +37,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "0.15.15",
"version": "0.15.18",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -64,7 +64,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "0.15.15",
"version": "0.15.18",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -88,7 +88,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "0.15.15",
"version": "0.15.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -109,11 +109,12 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "0.15.15",
"version": "0.15.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/precision-diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/event-bus": "1.1.2",
@@ -148,7 +149,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "0.15.15",
"version": "0.15.18",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -164,7 +165,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.15.15",
"version": "0.15.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -183,6 +184,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
@@ -227,7 +229,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "0.15.15",
"version": "0.15.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -247,7 +249,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "0.15.15",
"version": "0.15.18",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@@ -258,7 +260,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "0.15.15",
"version": "0.15.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -271,7 +273,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "0.15.15",
"version": "0.15.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
@@ -294,7 +296,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "0.15.15",
"version": "0.15.18",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -341,6 +343,7 @@
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
@@ -1072,7 +1075,7 @@
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA=="],
@@ -3510,8 +3513,6 @@
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
"@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
"@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="],
"@pierre/precision-diffs/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
@@ -3524,6 +3525,10 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
@@ -3788,6 +3793,8 @@
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"shiki/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"sitemap/sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -3942,8 +3949,6 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
@@ -4078,8 +4083,6 @@
"@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="],
"@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
"@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
"@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
@@ -4342,8 +4345,6 @@
"@actions/github/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
+1
View File
@@ -25,6 +25,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
+1 -1
View File
@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "0.15.15"
"version": "0.15.18"
},
"dependencies": {
"@ibm/plex": "6.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "0.15.15",
"version": "0.15.18",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "0.15.15",
"version": "0.15.18",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "0.15.15",
"version": "0.15.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "0.15.15",
"version": "0.15.18",
"description": "",
"type": "module",
"scripts": {
@@ -26,6 +26,7 @@
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/precision-diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/event-bus": "1.1.2",
+2 -2
View File
@@ -1,8 +1,8 @@
import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki"
import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
import { useLocal, useShiki } from "@/context"
import type { TextSelection } from "@/context/local"
import { useLocal, type TextSelection } from "@/context/local"
import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
import { useShiki } from "@/context/shiki"
type DefinedSelection = Exclude<TextSelection, undefined>
+159
View File
@@ -0,0 +1,159 @@
import {
type FileContents,
FileDiff,
type DiffLineAnnotation,
DiffFileRendererOptions,
registerCustomTheme,
} from "@pierre/precision-diffs"
import { ComponentProps, createEffect, splitProps } from "solid-js"
export type DiffProps<T = {}> = Omit<DiffFileRendererOptions<T>, "themes"> & {
before: FileContents
after: FileContents
annotations?: DiffLineAnnotation<T>[]
class?: string
classList?: ComponentProps<"div">["classList"]
}
// @ts-expect-error
registerCustomTheme("opencode", () => import("./theme.json"))
// interface ThreadMetadata {
// threadId: string
// }
export function Diff<T>(props: DiffProps<T>) {
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
// const lineAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [
// {
// side: "additions",
// // The line number specified for an annotation is the visual line number
// // you see in the number column of a diff
// lineNumber: 16,
// metadata: { threadId: "68b329da9893e34099c7d8ad5cb9c940" },
// },
// ]
// If you ever want to update the options for an instance, simple call
// 'setOptions' with the new options. Bear in mind, this does NOT merge
// existing properties, it's a full replace
// instance.setOptions({
// ...instance.options,
// theme: "pierre-dark",
// themes: undefined,
// })
// When ready to render, simply call .render with old/new file, optional
// annotations and a container element to hold the diff
createEffect(() => {
const instance = new FileDiff<T>({
theme: "opencode",
// Or can also provide a 'themes' prop, which allows the code to adapt
// to your OS light or dark theme
// themes: { dark: 'pierre-night', light: 'pierre-light' },
// When using the 'themes' prop, 'themeType' allows you to force 'dark'
// or 'light' theme, or inherit from the OS ('system') theme.
themeType: "system",
// Disable the line numbers for your diffs, generally not recommended
disableLineNumbers: false,
// Whether code should 'wrap' with long lines or 'scroll'.
overflow: "scroll",
// Normally you shouldn't need this prop, but if you don't provide a
// valid filename or your file doesn't have an extension you may want to
// override the automatic detection. You can specify that language here:
// https://shiki.style/languages
// lang?: SupportedLanguages;
// 'diffStyle' controls whether the diff is presented side by side or
// in a unified (single column) view
diffStyle: "unified",
// Line decorators to help highlight changes.
// 'bars' (default):
// Shows some red-ish or green-ish (theme dependent) bars on the left
// edge of relevant lines
//
// 'classic':
// shows '+' characters on additions and '-' characters on deletions
//
// 'none':
// No special diff indicators are shown
diffIndicators: "bars",
// By default green-ish or red-ish background are shown on added and
// deleted lines respectively. Disable that feature here
disableBackground: false,
// Diffs are split up into hunks, this setting customizes what to show
// between each hunk.
//
// 'line-info' (default):
// Shows a bar that tells you how many lines are collapsed. If you are
// using the oldFile/newFile API then you can click those bars to
// expand the content between them
//
// 'metadata':
// Shows the content you'd see in a normal patch file, usually in some
// format like '@@ -60,6 +60,22 @@'. You cannot use these to expand
// hidden content
//
// 'simple':
// Just a subtle bar separator between each hunk
hunkSeparators: "line-info",
// On lines that have both additions and deletions, we can run a
// separate diff check to mark parts of the lines that change.
// 'none':
// Do not show these secondary highlights
//
// 'char':
// Show changes at a per character granularity
//
// 'word':
// Show changes but rounded up to word boundaries
//
// 'word-alt' (default):
// Similar to 'word', however we attempt to minimize single character
// gaps between highlighted changes
lineDiffType: "word-alt",
// If lines exceed these character lengths then we won't perform the
// line lineDiffType check
maxLineDiffLength: 1000,
// If any line in the diff exceeds this value then we won't attempt to
// syntax highlight the diff
maxLineLengthForHighlighting: 1000,
// Enabling this property will hide the file header with file name and
// diff stats.
disableFileHeader: true,
// You can optionally pass a render function for rendering out line
// annotations. Just return the dom node to render
// renderAnnotation(annotation: DiffLineAnnotation<T>): HTMLElement {
// // Despite the diff itself being rendered in the shadow dom,
// // annotations are inserted via the web components 'slots' api and you
// // can use all your normal normal css and styling for them
// const element = document.createElement("div")
// element.innerText = annotation.metadata.threadId
// return element
// },
...others,
})
instance.render({
oldFile: local.before,
newFile: local.after,
lineAnnotations: local.annotations,
containerWrapper: container,
})
})
return (
<div
style={{
"--pjs-font-family": "var(--font-family-mono)",
"--pjs-font-size": "var(--font-size-small)",
"--pjs-line-height": "24px",
"--pjs-tab-size": 4,
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
"--pjs-header-font-family": "var(--font-family-sans)",
}}
ref={container}
/>
)
}
@@ -1,252 +0,0 @@
import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
import { IconButton, Tabs, Tooltip } from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
closestCenter,
createSortable,
useDragDropContext,
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { LocalFile } from "@/context/local"
import { Code } from "@/components/code"
import { useLocal } from "@/context"
import type { JSX } from "solid-js"
interface EditorPaneProps {
onFileClick: (file: LocalFile) => void
}
export default function EditorPane(props: EditorPaneProps): JSX.Element {
const [localProps] = splitProps(props, ["onFileClick"])
const local = useLocal()
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
const navigateChange = (dir: 1 | -1) => {
const active = local.file.active()
if (!active) return
const current = local.file.changeIndex(active.path)
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
local.file.setChangeIndex(active.path, next)
}
const handleTabChange = (path: string) => {
local.file.open(path)
}
const handleTabClose = (file: LocalFile) => {
local.file.close(file.path)
}
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setActiveItem(id)
}
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const currentFiles = local.file.opened().map((file) => file.path)
const fromIndex = currentFiles.indexOf(draggable.id.toString())
const toIndex = currentFiles.indexOf(droppable.id.toString())
if (fromIndex !== toIndex) {
local.file.move(draggable.id.toString(), toIndex)
}
}
}
const handleDragEnd = () => {
setActiveItem(undefined)
}
return (
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={local.file.active()?.path} onChange={handleTabChange}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
<For each={local.file.opened()}>
{(file) => <SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} />}
</For>
</SortableProvider>
</Tabs.List>
<div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
<Show when={local.file.active() && local.file.active()!.content?.diff}>
{(() => {
const activeFile = local.file.active()!
const view = local.file.view(activeFile.path)
return (
<div class="flex items-center gap-1">
<Show when={view !== "raw"}>
<div class="mr-1 flex items-center gap-1">
<Tooltip value="Previous change" placement="bottom">
<IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
</Tooltip>
<Tooltip value="Next change" placement="bottom">
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
</Tooltip>
</div>
</Show>
<Tooltip value="Raw" placement="bottom">
<IconButton
icon="file-text"
variant="ghost"
classList={{
"text-text": view === "raw",
"text-text-muted/70": view !== "raw",
"bg-background-element": view === "raw",
}}
onClick={() => local.file.setView(activeFile.path, "raw")}
/>
</Tooltip>
<Tooltip value="Unified diff" placement="bottom">
<IconButton
icon="checklist"
variant="ghost"
classList={{
"text-text": view === "diff-unified",
"text-text-muted/70": view !== "diff-unified",
"bg-background-element": view === "diff-unified",
}}
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
/>
</Tooltip>
<Tooltip value="Split diff" placement="bottom">
<IconButton
icon="columns"
variant="ghost"
classList={{
"text-text": view === "diff-split",
"text-text-muted/70": view !== "diff-split",
"bg-background-element": view === "diff-split",
}}
onClick={() => local.file.setView(activeFile.path, "diff-split")}
/>
</Tooltip>
</div>
)
})()}
</Show>
</div>
</div>
<For each={local.file.opened()}>
{(file) => (
<Tabs.Content value={file.path} class="select-text">
{(() => {
const view = local.file.view(file.path)
const showRaw = view === "raw" || !file.content?.diff
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
})()}
</Tabs.Content>
)}
</For>
</Tabs>
<DragOverlay>
{(() => {
const id = activeItem()
if (!id) return null
const draggedFile = local.file.node(id)
if (!draggedFile) return null
return (
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
<TabVisual file={draggedFile} />
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
)
}
function TabVisual(props: { file: LocalFile }): JSX.Element {
return (
<div class="flex items-center gap-x-1.5">
<FileIcon node={props.file} class="" />
<span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
{props.file.name}
</span>
<span class="text-xs opacity-70">
<Switch>
<Match when={props.file.status?.status === "modified"}>
<span class="text-primary">M</span>
</Match>
<Match when={props.file.status?.status === "added"}>
<span class="text-success">A</span>
</Match>
<Match when={props.file.status?.status === "deleted"}>
<span class="text-error">D</span>
</Match>
</Switch>
</span>
</div>
)
}
function SortableTab(props: {
file: LocalFile
onTabClick: (file: LocalFile) => void
onTabClose: (file: LocalFile) => void
}): JSX.Element {
const sortable = createSortable(props.file.path)
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
<Tooltip value={props.file.path} placement="bottom">
<div class="relative">
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
<TabVisual file={props.file} />
</Tabs.Trigger>
<IconButton
icon="close"
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
variant="ghost"
onClick={() => props.onTabClose(props.file)}
/>
</div>
</Tooltip>
</div>
)
}
function ConstrainDragYAxis(): JSX.Element {
const context = useDragDropContext()
if (!context) return <></>
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = {
id: "constrain-y-axis",
order: 100,
callback: (transform) => ({ ...transform, y: 0 }),
}
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return <></>
}
const getDraggableId = (event: unknown): string | undefined => {
if (typeof event !== "object" || event === null) return undefined
if (!("draggable" in event)) return undefined
const draggable = (event as { draggable?: { id?: unknown } }).draggable
if (!draggable) return undefined
return typeof draggable.id === "string" ? draggable.id : undefined
}
@@ -1,5 +1,4 @@
import { useLocal } from "@/context"
import type { LocalFile } from "@/context/local"
import { useLocal, type LocalFile } from "@/context/local"
import { Tooltip } from "@opencode-ai/ui"
import { Collapsible, FileIcon } from "@/ui"
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
+1 -1
View File
@@ -1,4 +1,4 @@
import { useMarked } from "@/context"
import { useMarked } from "@/context/marked"
import { createResource } from "solid-js"
function strip(text: string): string {
@@ -1,12 +1,11 @@
import { useLocal } from "@/context"
import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, createMemo, Show, Switch, Match, For } from "solid-js"
import { createEffect, on, Component, createMemo, Show, For, onMount, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils"
import { createFocusSignal } from "@solid-primitives/active-element"
import { TextSelection } from "@/context/local"
import { TextSelection, useLocal } from "@/context/local"
import { DateTime } from "luxon"
interface PartBase {
@@ -47,6 +46,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
const isFocused = createFocusSignal(() => editorRef)
const handlePaste = (event: ClipboardEvent) => {
event.preventDefault()
event.stopPropagation()
// @ts-expect-error
const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
addPart({ type: "text", content: plainText })
}
onMount(() => {
editorRef.addEventListener("paste", handlePaste)
})
onCleanup(() => {
editorRef.removeEventListener("paste", handlePaste)
})
createEffect(() => {
if (isFocused()) {
handleInput()
@@ -145,16 +159,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawText = store.contentParts.map((p) => p.content).join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
if (!atMatch) return
const startIndex = atMatch.index!
const endIndex = cursorPosition
const startIndex = atMatch ? atMatch.index! : cursorPosition
const endIndex = atMatch ? cursorPosition : cursorPosition
const pushText = (acc: { parts: ContentPart[] }, value: string) => {
if (!value) return
const last = acc.parts[acc.parts.length - 1]
if (last && last.type === "text") {
acc.parts[acc.parts.length - 1] = {
type: "text",
content: last.content + value,
}
return
}
acc.parts.push({ type: "text", content: value })
}
const {
parts: nextParts,
cursorIndex,
cursorOffset,
inserted,
cursorPositionAfter,
} = store.contentParts.reduce(
(acc, item) => {
if (acc.inserted) {
@@ -181,16 +206,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const head = item.content.slice(0, headLength)
const tail = item.content.slice(tailLength)
if (head) acc.parts.push({ type: "text", content: head })
pushText(acc, head)
acc.parts.push(part)
const rest = /^\s/.test(tail) ? tail : ` ${tail}`
if (rest) {
acc.cursorIndex = acc.parts.length
acc.cursorOffset = Math.min(1, rest.length)
acc.parts.push({ type: "text", content: rest })
if (part.type === "text") {
pushText(acc, part.content)
}
if (part.type !== "text") {
acc.parts.push({ ...part })
}
const needsGap = Boolean(atMatch)
const rest = needsGap ? (tail ? (/^\s/.test(tail) ? tail : ` ${tail}`) : " ") : tail
pushText(acc, rest)
const baseCursor = startIndex + part.content.length
const cursorAddition = needsGap && rest.length > 0 ? 1 : 0
acc.cursorPositionAfter = baseCursor + cursorAddition
acc.inserted = true
acc.runningIndex = nextIndex
@@ -200,29 +231,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
parts: [] as ContentPart[],
runningIndex: 0,
inserted: false,
cursorIndex: null as number | null,
cursorOffset: 0,
cursorPositionAfter: cursorPosition + part.content.length,
},
)
if (!inserted || cursorIndex === null) return
if (!inserted) {
const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
const appendedAcc = { parts: [...baseParts] as ContentPart[] }
if (part.type === "text") pushText(appendedAcc, part.content)
if (part.type !== "text") appendedAcc.parts.push({ ...part })
const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
setStore("contentParts", next)
setStore("popoverIsOpen", false)
const nextCursor = rawText.length + part.content.length
queueMicrotask(() => setCursorPosition(editorRef, nextCursor))
return
}
setStore("contentParts", nextParts)
setStore("popoverIsOpen", false)
queueMicrotask(() => {
const node = editorRef.childNodes[cursorIndex]
if (node && node.nodeType === Node.TEXT_NODE) {
const range = document.createRange()
const selection = window.getSelection()
const length = node.textContent ? node.textContent.length : 0
const offset = cursorOffset > length ? length : cursorOffset
range.setStart(node, offset)
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
}
})
queueMicrotask(() => setCursorPosition(editorRef, cursorPositionAfter))
}
const handleKeyDown = (event: KeyboardEvent) => {
@@ -245,7 +274,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
return (
<div class="relative size-full max-w-[640px] _max-h-[320px] flex flex-col gap-3">
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popoverIsOpen}>
<div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
<For each={flat()}>
@@ -1,4 +1,3 @@
import { useLocal, useSync } from "@/context"
import { Icon, Tooltip } from "@opencode-ai/ui"
import { Collapsible } from "@/ui"
import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
@@ -22,6 +21,8 @@ import { createElementSize } from "@solid-primitives/resize-observer"
import { createScrollPosition } from "@solid-primitives/scroll"
import { ProgressCircle } from "./progress-circle"
import { pipe, sumBy } from "remeda"
import { useSync } from "@/context/sync"
import { useLocal } from "@/context/local"
function Part(props: ParentProps & ComponentProps<"div">) {
const [local, others] = splitProps(props, ["class", "classList", "children"])
@@ -386,6 +387,11 @@ export default function SessionTimeline(props: { session: string; class?: string
}
}
createEffect(() => {
console.log("WHAT")
console.log(JSON.stringify(messagesWithValidParts()))
})
return (
<div
ref={setRoot}
@@ -394,7 +400,7 @@ export default function SessionTimeline(props: { session: string; class?: string
[props.class ?? ""]: !!props.class,
}}
>
<div class="py-1.5 px-6 flex justify-end items-center self-stretch">
<div class="flex justify-end items-center self-stretch">
<div class="flex items-center gap-6">
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
<Show when={context()}>
@@ -405,7 +411,7 @@ export default function SessionTimeline(props: { session: string; class?: string
<div class="text-14-regular text-text-strong text-right">{cost()}</div>
</div>
</div>
<ul role="list" class="flex flex-col items-start self-stretch px-6 pt-2 pb-6 gap-1">
<ul role="list" class="flex flex-col items-start self-stretch">
<For each={messagesWithValidParts()}>
{(message) => (
<div
+558
View File
@@ -0,0 +1,558 @@
{
"colors": {
"actionBar.toggledBackground": "var(--surface-raised-base)",
"activityBarBadge.background": "var(--surface-brand-base)",
"checkbox.border": "var(--border-base)",
"editor.background": "transparent",
"editor.foreground": "var(--text-base)",
"editor.inactiveSelectionBackground": "var(--surface-raised-base)",
"editor.selectionHighlightBackground": "var(--border-active)",
"editorIndentGuide.activeBackground1": "var(--border-weak-base)",
"editorIndentGuide.background1": "var(--border-weak-base)",
"input.placeholderForeground": "var(--text-weak)",
"list.activeSelectionIconForeground": "var(--text-base)",
"list.dropBackground": "var(--surface-raised-base)",
"menu.background": "var(--surface-base)",
"menu.border": "var(--border-base)",
"menu.foreground": "var(--text-base)",
"menu.selectionBackground": "var(--surface-interactive-base)",
"menu.separatorBackground": "var(--border-base)",
"ports.iconRunningProcessForeground": "var(--icon-success-base)",
"sideBarSectionHeader.background": "transparent",
"sideBarSectionHeader.border": "var(--border-weak-base)",
"sideBarTitle.foreground": "var(--text-weak)",
"statusBarItem.remoteBackground": "var(--surface-success-base)",
"statusBarItem.remoteForeground": "var(--text-base)",
"tab.lastPinnedBorder": "var(--border-weak-base)",
"tab.selectedBackground": "var(--surface-raised-base)",
"tab.selectedForeground": "var(--text-weak)",
"terminal.inactiveSelectionBackground": "var(--surface-raised-base)",
"widget.border": "var(--border-base)"
},
"displayName": "opencode",
"name": "opencode",
"semanticHighlighting": true,
"semanticTokenColors": {
"customLiteral": "var(--syntax-function)",
"newOperator": "var(--syntax-operator)",
"numberLiteral": "var(--syntax-number)",
"stringLiteral": "var(--syntax-string)"
},
"tokenColors": [
{
"scope": [
"meta.embedded",
"source.groovy.embedded",
"string meta.image.inline.markdown",
"variable.legacy.builtin.python"
],
"settings": {
"foreground": "var(--text-base)"
}
},
{
"scope": "emphasis",
"settings": {
"fontStyle": "italic"
}
},
{
"scope": "strong",
"settings": {
"fontStyle": "bold"
}
},
{
"scope": "header",
"settings": {
"foreground": "var(--markdown-heading)"
}
},
{
"scope": "comment",
"settings": {
"foreground": "var(--syntax-comment)"
}
},
{
"scope": "constant.language",
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": [
"constant.numeric",
"variable.other.enummember",
"keyword.operator.plus.exponent",
"keyword.operator.minus.exponent"
],
"settings": {
"foreground": "var(--syntax-number)"
}
},
{
"scope": "constant.regexp",
"settings": {
"foreground": "var(--syntax-operator)"
}
},
{
"scope": "entity.name.tag",
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": ["entity.name.tag.css", "entity.name.tag.less"],
"settings": {
"foreground": "var(--syntax-operator)"
}
},
{
"scope": "entity.other.attribute-name",
"settings": {
"foreground": "var(--syntax-variable)"
}
},
{
"scope": [
"entity.other.attribute-name.class.css",
"source.css entity.other.attribute-name.class",
"entity.other.attribute-name.id.css",
"entity.other.attribute-name.parent-selector.css",
"entity.other.attribute-name.parent.less",
"source.css entity.other.attribute-name.pseudo-class",
"entity.other.attribute-name.pseudo-element.css",
"source.css.less entity.other.attribute-name.id",
"entity.other.attribute-name.scss"
],
"settings": {
"foreground": "var(--syntax-operator)"
}
},
{
"scope": "invalid",
"settings": {
"foreground": "var(--syntax-critical)"
}
},
{
"scope": "markup.underline",
"settings": {
"fontStyle": "underline"
}
},
{
"scope": "markup.bold",
"settings": {
"fontStyle": "bold",
"foreground": "var(--markdown-strong)"
}
},
{
"scope": "markup.heading",
"settings": {
"fontStyle": "bold",
"foreground": "var(--theme-markdown-heading)"
}
},
{
"scope": "markup.italic",
"settings": {
"fontStyle": "italic"
}
},
{
"scope": "markup.strikethrough",
"settings": {
"fontStyle": "strikethrough"
}
},
{
"scope": "markup.inserted",
"settings": {
"foreground": "var(--text-diff-add-base)"
}
},
{
"scope": "markup.deleted",
"settings": {
"foreground": "var(--text-diff-delete-base)"
}
},
{
"scope": "markup.changed",
"settings": {
"foreground": "var(--text-base)"
}
},
{
"scope": "punctuation.definition.quote.begin.markdown",
"settings": {
"foreground": "var(--markdown-block-quote)"
}
},
{
"scope": "punctuation.definition.list.begin.markdown",
"settings": {
"foreground": "var(--markdown-list-enumeration)"
}
},
{
"scope": "markup.inline.raw",
"settings": {
"foreground": "var(--markdown-code)"
}
},
{
"scope": "punctuation.definition.tag",
"settings": {
"foreground": "var(--syntax-punctuation)"
}
},
{
"scope": ["meta.preprocessor", "entity.name.function.preprocessor"],
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": "meta.preprocessor.string",
"settings": {
"foreground": "var(--syntax-string)"
}
},
{
"scope": "meta.preprocessor.numeric",
"settings": {
"foreground": "var(--syntax-number)"
}
},
{
"scope": "meta.structure.dictionary.key.python",
"settings": {
"foreground": "var(--syntax-variable)"
}
},
{
"scope": "meta.diff.header",
"settings": {
"foreground": "var(--text-weak)"
}
},
{
"scope": "storage",
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": "storage.type",
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": ["storage.modifier", "keyword.operator.noexcept"],
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": ["string", "meta.embedded.assembly"],
"settings": {
"foreground": "var(--syntax-string)"
}
},
{
"scope": "string.tag",
"settings": {
"foreground": "var(--syntax-string)"
}
},
{
"scope": "string.value",
"settings": {
"foreground": "var(--syntax-string)"
}
},
{
"scope": "string.regexp",
"settings": {
"foreground": "var(--syntax-operator)"
}
},
{
"scope": [
"punctuation.definition.template-expression.begin",
"punctuation.definition.template-expression.end",
"punctuation.section.embedded"
],
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": ["meta.template.expression"],
"settings": {
"foreground": "var(--text-base)"
}
},
{
"scope": [
"support.type.vendored.property-name",
"support.type.property-name",
"source.css variable",
"source.coffee.embedded"
],
"settings": {
"foreground": "var(--syntax-variable)"
}
},
{
"scope": "keyword",
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": "keyword.control",
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": "keyword.operator",
"settings": {
"foreground": "var(--syntax-operator)"
}
},
{
"scope": [
"keyword.operator.new",
"keyword.operator.expression",
"keyword.operator.cast",
"keyword.operator.sizeof",
"keyword.operator.alignof",
"keyword.operator.typeid",
"keyword.operator.alignas",
"keyword.operator.instanceof",
"keyword.operator.logical.python",
"keyword.operator.wordlike"
],
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": "keyword.other.unit",
"settings": {
"foreground": "var(--syntax-number)"
}
},
{
"scope": ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": "support.function.git-rebase",
"settings": {
"foreground": "var(--syntax-variable)"
}
},
{
"scope": "constant.sha.git-rebase",
"settings": {
"foreground": "var(--syntax-number)"
}
},
{
"scope": ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"],
"settings": {
"foreground": "var(--text-base)"
}
},
{
"scope": "variable.language",
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": [
"entity.name.function",
"support.function",
"support.constant.handlebars",
"source.powershell variable.other.member",
"entity.name.operator.custom-literal"
],
"settings": {
"foreground": "var(--syntax-function)"
}
},
{
"scope": [
"support.class",
"support.type",
"entity.name.type",
"entity.name.namespace",
"entity.other.attribute",
"entity.name.scope-resolution",
"entity.name.class",
"storage.type.numeric.go",
"storage.type.byte.go",
"storage.type.boolean.go",
"storage.type.string.go",
"storage.type.uintptr.go",
"storage.type.error.go",
"storage.type.rune.go",
"storage.type.cs",
"storage.type.generic.cs",
"storage.type.modifier.cs",
"storage.type.variable.cs",
"storage.type.annotation.java",
"storage.type.generic.java",
"storage.type.java",
"storage.type.object.array.java",
"storage.type.primitive.array.java",
"storage.type.primitive.java",
"storage.type.token.java",
"storage.type.groovy",
"storage.type.annotation.groovy",
"storage.type.parameters.groovy",
"storage.type.generic.groovy",
"storage.type.object.array.groovy",
"storage.type.primitive.array.groovy",
"storage.type.primitive.groovy"
],
"settings": {
"foreground": "var(--syntax-type)"
}
},
{
"scope": [
"meta.type.cast.expr",
"meta.type.new.expr",
"support.constant.math",
"support.constant.dom",
"support.constant.json",
"entity.other.inherited-class",
"punctuation.separator.namespace.ruby"
],
"settings": {
"foreground": "var(--syntax-type)"
}
},
{
"scope": [
"keyword.control",
"source.cpp keyword.operator.new",
"keyword.operator.delete",
"keyword.other.using",
"keyword.other.directive.using",
"keyword.other.operator",
"entity.name.operator"
],
"settings": {
"foreground": "var(--syntax-operator)"
}
},
{
"scope": [
"variable",
"meta.definition.variable.name",
"support.variable",
"entity.name.variable",
"constant.other.placeholder"
],
"settings": {
"foreground": "var(--syntax-variable)"
}
},
{
"scope": ["variable.other.constant", "variable.other.enummember"],
"settings": {
"foreground": "var(--syntax-variable)"
}
},
{
"scope": ["meta.object-literal.key"],
"settings": {
"foreground": "var(--syntax-variable)"
}
},
{
"scope": [
"support.constant.property-value",
"support.constant.font-name",
"support.constant.media-type",
"support.constant.media",
"constant.other.color.rgb-value",
"constant.other.rgb-value",
"support.constant.color"
],
"settings": {
"foreground": "var(--syntax-string)"
}
},
{
"scope": [
"punctuation.definition.group.regexp",
"punctuation.definition.group.assertion.regexp",
"punctuation.definition.character-class.regexp",
"punctuation.character.set.begin.regexp",
"punctuation.character.set.end.regexp",
"keyword.operator.negation.regexp",
"support.other.parenthesis.regexp"
],
"settings": {
"foreground": "var(--syntax-string)"
}
},
{
"scope": [
"constant.character.character-class.regexp",
"constant.other.character-class.set.regexp",
"constant.other.character-class.regexp",
"constant.character.set.regexp"
],
"settings": {
"foreground": "var(--syntax-operator)"
}
},
{
"scope": ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
"settings": {
"foreground": "var(--syntax-operator)"
}
},
{
"scope": "keyword.operator.quantifier.regexp",
"settings": {
"foreground": "var(--syntax-operator)"
}
},
{
"scope": ["constant.character", "constant.other.option"],
"settings": {
"foreground": "var(--syntax-keyword)"
}
},
{
"scope": "constant.character.escape",
"settings": {
"foreground": "var(--syntax-operator)"
}
},
{
"scope": "entity.name.label",
"settings": {
"foreground": "var(--text-weak)"
}
}
],
"type": "dark"
}
-34
View File
@@ -1,34 +0,0 @@
import { createContext, useContext, type ParentProps } from "solid-js"
import { createEventBus } from "@solid-primitives/event-bus"
import type { Event as SDKEvent } from "@opencode-ai/sdk"
import { useSDK } from "@/context"
export type Event = SDKEvent // can extend with custom events later
function init() {
const sdk = useSDK()
const bus = createEventBus<Event>()
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
bus.emit(event)
}
})
return bus
}
type EventContext = ReturnType<typeof init>
const ctx = createContext<EventContext>()
export function EventProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useEvent() {
const value = useContext(ctx)
if (!value) {
throw new Error("useEvent must be used within a EventProvider")
}
return value
}
+25
View File
@@ -0,0 +1,25 @@
import { createContext, Show, useContext, type ParentProps } from "solid-js"
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
name: string
init: ((input: Props) => T) | (() => T)
}) {
const ctx = createContext<T>()
return {
provider: (props: ParentProps<Props>) => {
const init = input.init(props)
return (
// @ts-expect-error
<Show when={init.ready === undefined || init.ready === true}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const value = useContext(ctx)
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
return value
},
}
}
-6
View File
@@ -1,6 +0,0 @@
export { EventProvider, useEvent } from "./event"
export { LocalProvider, useLocal } from "./local"
export { MarkedProvider, useMarked } from "./marked"
export { SDKProvider, useSDK } from "./sdk"
export { ShikiProvider, useShiki } from "./shiki"
export { SyncProvider, useSync } from "./sync"
File diff suppressed because it is too large Load Diff
+25 -38
View File
@@ -1,43 +1,30 @@
import { createContext, useContext, type ParentProps } from "solid-js"
import { useShiki } from "@/context"
import { marked } from "marked"
import markedShiki from "marked-shiki"
import { bundledLanguages, type BundledLanguage } from "shiki"
function init(highlighter: ReturnType<typeof useShiki>) {
return marked.use(
markedShiki({
async highlight(code, lang) {
if (!(lang in bundledLanguages)) {
lang = "text"
}
if (!highlighter.getLoadedLanguages().includes(lang)) {
await highlighter.loadLanguage(lang as BundledLanguage)
}
return highlighter.codeToHtml(code, {
lang: lang || "text",
theme: "opencode",
tabindex: false,
})
},
}),
)
}
import { createSimpleContext } from "./helper"
import { useShiki } from "./shiki"
type MarkedContext = ReturnType<typeof init>
const ctx = createContext<MarkedContext>()
export function MarkedProvider(props: ParentProps) {
const highlighter = useShiki()
const value = init(highlighter)
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useMarked() {
const value = useContext(ctx)
if (!value) {
throw new Error("useMarked must be used within a MarkedProvider")
}
return value
}
export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
name: "Marked",
init: () => {
const highlighter = useShiki()
return marked.use(
markedShiki({
async highlight(code, lang) {
if (!(lang in bundledLanguages)) {
lang = "text"
}
if (!highlighter.getLoadedLanguages().includes(lang)) {
await highlighter.loadLanguage(lang as BundledLanguage)
}
return highlighter.codeToHtml(code, {
lang: lang || "text",
theme: "opencode",
tabindex: false,
})
},
}),
)
},
})
+32 -24
View File
@@ -1,29 +1,37 @@
import { createContext, useContext, type ParentProps } from "solid-js"
import { createOpencodeClient } from "@opencode-ai/sdk/client"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { url: string }) => {
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
fetch: (req) => {
// @ts-ignore
req.timeout = false
return fetch(req)
},
})
function init() {
const client = createOpencodeClient({
baseUrl: `http://${host}:${port}`,
})
return client
}
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
type SDKContext = ReturnType<typeof init>
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
console.log("event", event.type)
emitter.emit(event.type, event)
}
})
const ctx = createContext<SDKContext>()
onCleanup(() => {
abort.abort()
})
export function SDKProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useSDK() {
const value = useContext(ctx)
if (!value) {
throw new Error("useSDK must be used within a SDKProvider")
}
return value
}
return { client: sdk, event: emitter }
},
})
+96 -105
View File
@@ -1,45 +1,45 @@
import { createSimpleContext } from "./helper"
import { createHighlighter, type ThemeInput } from "shiki"
import { createContext, useContext, type ParentProps } from "solid-js"
const theme: ThemeInput = {
colors: {
"actionBar.toggledBackground": "var(--theme-background-element)",
"activityBarBadge.background": "var(--theme-accent)",
"checkbox.border": "var(--theme-border)",
"actionBar.toggledBackground": "var(--surface-raised-base)",
"activityBarBadge.background": "var(--surface-brand-base)",
"checkbox.border": "var(--border-base)",
"editor.background": "transparent",
"editor.foreground": "var(--theme-text)",
"editor.inactiveSelectionBackground": "var(--theme-background-element)",
"editor.selectionHighlightBackground": "var(--theme-border-active)",
"editorIndentGuide.activeBackground1": "var(--theme-border-subtle)",
"editorIndentGuide.background1": "var(--theme-border-subtle)",
"input.placeholderForeground": "var(--theme-text-muted)",
"list.activeSelectionIconForeground": "var(--theme-text)",
"list.dropBackground": "var(--theme-background-element)",
"menu.background": "var(--theme-background-panel)",
"menu.border": "var(--theme-border)",
"menu.foreground": "var(--theme-text)",
"menu.selectionBackground": "var(--theme-primary)",
"menu.separatorBackground": "var(--theme-border)",
"ports.iconRunningProcessForeground": "var(--theme-success)",
"editor.foreground": "var(--text-base)",
"editor.inactiveSelectionBackground": "var(--surface-raised-base)",
"editor.selectionHighlightBackground": "var(--border-active)",
"editorIndentGuide.activeBackground1": "var(--border-weak-base)",
"editorIndentGuide.background1": "var(--border-weak-base)",
"input.placeholderForeground": "var(--text-weak)",
"list.activeSelectionIconForeground": "var(--text-base)",
"list.dropBackground": "var(--surface-raised-base)",
"menu.background": "var(--surface-base)",
"menu.border": "var(--border-base)",
"menu.foreground": "var(--text-base)",
"menu.selectionBackground": "var(--surface-interactive-base)",
"menu.separatorBackground": "var(--border-base)",
"ports.iconRunningProcessForeground": "var(--icon-success-base)",
"sideBarSectionHeader.background": "transparent",
"sideBarSectionHeader.border": "var(--theme-border-subtle)",
"sideBarTitle.foreground": "var(--theme-text-muted)",
"statusBarItem.remoteBackground": "var(--theme-success)",
"statusBarItem.remoteForeground": "var(--theme-text)",
"tab.lastPinnedBorder": "var(--theme-border-subtle)",
"tab.selectedBackground": "var(--theme-background-element)",
"tab.selectedForeground": "var(--theme-text-muted)",
"terminal.inactiveSelectionBackground": "var(--theme-background-element)",
"widget.border": "var(--theme-border)",
"sideBarSectionHeader.border": "var(--border-weak-base)",
"sideBarTitle.foreground": "var(--text-weak)",
"statusBarItem.remoteBackground": "var(--surface-success-base)",
"statusBarItem.remoteForeground": "var(--text-base)",
"tab.lastPinnedBorder": "var(--border-weak-base)",
"tab.selectedBackground": "var(--surface-raised-base)",
"tab.selectedForeground": "var(--text-weak)",
"terminal.inactiveSelectionBackground": "var(--surface-raised-base)",
"widget.border": "var(--border-base)",
},
displayName: "opencode",
name: "opencode",
semanticHighlighting: true,
semanticTokenColors: {
customLiteral: "var(--theme-syntax-function)",
newOperator: "var(--theme-syntax-operator)",
numberLiteral: "var(--theme-syntax-number)",
stringLiteral: "var(--theme-syntax-string)",
customLiteral: "var(--syntax-function)",
newOperator: "var(--syntax-operator)",
numberLiteral: "var(--syntax-number)",
stringLiteral: "var(--syntax-string)",
},
tokenColors: [
{
@@ -50,7 +50,7 @@ const theme: ThemeInput = {
"variable.legacy.builtin.python",
],
settings: {
foreground: "var(--theme-text)",
foreground: "var(--text-base)",
},
},
{
@@ -68,19 +68,19 @@ const theme: ThemeInput = {
{
scope: "header",
settings: {
foreground: "var(--theme-markdown-heading)",
foreground: "var(--markdown-heading)",
},
},
{
scope: "comment",
settings: {
foreground: "var(--theme-syntax-comment)",
foreground: "var(--syntax-comment)",
},
},
{
scope: "constant.language",
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
@@ -91,31 +91,31 @@ const theme: ThemeInput = {
"keyword.operator.minus.exponent",
],
settings: {
foreground: "var(--theme-syntax-number)",
foreground: "var(--syntax-number)",
},
},
{
scope: "constant.regexp",
settings: {
foreground: "var(--theme-syntax-operator)",
foreground: "var(--syntax-operator)",
},
},
{
scope: "entity.name.tag",
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: ["entity.name.tag.css", "entity.name.tag.less"],
settings: {
foreground: "var(--theme-syntax-operator)",
foreground: "var(--syntax-operator)",
},
},
{
scope: "entity.other.attribute-name",
settings: {
foreground: "var(--theme-syntax-variable)",
foreground: "var(--syntax-variable)",
},
},
{
@@ -131,13 +131,13 @@ const theme: ThemeInput = {
"entity.other.attribute-name.scss",
],
settings: {
foreground: "var(--theme-syntax-operator)",
foreground: "var(--syntax-operator)",
},
},
{
scope: "invalid",
settings: {
foreground: "var(--theme-error)",
foreground: "var(--syntax-critical)",
},
},
{
@@ -150,7 +150,7 @@ const theme: ThemeInput = {
scope: "markup.bold",
settings: {
fontStyle: "bold",
foreground: "var(--theme-markdown-strong)",
foreground: "var(--markdown-strong)",
},
},
{
@@ -175,115 +175,115 @@ const theme: ThemeInput = {
{
scope: "markup.inserted",
settings: {
foreground: "var(--theme-diff-added)",
foreground: "var(--text-diff-add-base)",
},
},
{
scope: "markup.deleted",
settings: {
foreground: "var(--theme-diff-removed)",
foreground: "var(--text-diff-delete-base)",
},
},
{
scope: "markup.changed",
settings: {
foreground: "var(--theme-diff-context)",
foreground: "var(--text-base)",
},
},
{
scope: "punctuation.definition.quote.begin.markdown",
settings: {
foreground: "var(--theme-markdown-block-quote)",
foreground: "var(--markdown-block-quote)",
},
},
{
scope: "punctuation.definition.list.begin.markdown",
settings: {
foreground: "var(--theme-markdown-list-enumeration)",
foreground: "var(--markdown-list-enumeration)",
},
},
{
scope: "markup.inline.raw",
settings: {
foreground: "var(--theme-markdown-code)",
foreground: "var(--markdown-code)",
},
},
{
scope: "punctuation.definition.tag",
settings: {
foreground: "var(--theme-syntax-punctuation)",
foreground: "var(--syntax-punctuation)",
},
},
{
scope: ["meta.preprocessor", "entity.name.function.preprocessor"],
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: "meta.preprocessor.string",
settings: {
foreground: "var(--theme-syntax-string)",
foreground: "var(--syntax-string)",
},
},
{
scope: "meta.preprocessor.numeric",
settings: {
foreground: "var(--theme-syntax-number)",
foreground: "var(--syntax-number)",
},
},
{
scope: "meta.structure.dictionary.key.python",
settings: {
foreground: "var(--theme-syntax-variable)",
foreground: "var(--syntax-variable)",
},
},
{
scope: "meta.diff.header",
settings: {
foreground: "var(--theme-diff-hunk-header)",
foreground: "var(--text-weak)",
},
},
{
scope: "storage",
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: "storage.type",
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: ["storage.modifier", "keyword.operator.noexcept"],
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: ["string", "meta.embedded.assembly"],
settings: {
foreground: "var(--theme-syntax-string)",
foreground: "var(--syntax-string)",
},
},
{
scope: "string.tag",
settings: {
foreground: "var(--theme-syntax-string)",
foreground: "var(--syntax-string)",
},
},
{
scope: "string.value",
settings: {
foreground: "var(--theme-syntax-string)",
foreground: "var(--syntax-string)",
},
},
{
scope: "string.regexp",
settings: {
foreground: "var(--theme-syntax-operator)",
foreground: "var(--syntax-operator)",
},
},
{
@@ -293,13 +293,13 @@ const theme: ThemeInput = {
"punctuation.section.embedded",
],
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: ["meta.template.expression"],
settings: {
foreground: "var(--theme-text)",
foreground: "var(--text-base)",
},
},
{
@@ -310,25 +310,25 @@ const theme: ThemeInput = {
"source.coffee.embedded",
],
settings: {
foreground: "var(--theme-syntax-variable)",
foreground: "var(--syntax-variable)",
},
},
{
scope: "keyword",
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: "keyword.control",
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: "keyword.operator",
settings: {
foreground: "var(--theme-syntax-operator)",
foreground: "var(--syntax-operator)",
},
},
{
@@ -345,43 +345,43 @@ const theme: ThemeInput = {
"keyword.operator.wordlike",
],
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: "keyword.other.unit",
settings: {
foreground: "var(--theme-syntax-number)",
foreground: "var(--syntax-number)",
},
},
{
scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: "support.function.git-rebase",
settings: {
foreground: "var(--theme-syntax-variable)",
foreground: "var(--syntax-variable)",
},
},
{
scope: "constant.sha.git-rebase",
settings: {
foreground: "var(--theme-syntax-number)",
foreground: "var(--syntax-number)",
},
},
{
scope: ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"],
settings: {
foreground: "var(--theme-text)",
foreground: "var(--text-base)",
},
},
{
scope: "variable.language",
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
@@ -393,7 +393,7 @@ const theme: ThemeInput = {
"entity.name.operator.custom-literal",
],
settings: {
foreground: "var(--theme-syntax-function)",
foreground: "var(--syntax-function)",
},
},
{
@@ -432,7 +432,7 @@ const theme: ThemeInput = {
"storage.type.primitive.groovy",
],
settings: {
foreground: "var(--theme-syntax-type)",
foreground: "var(--syntax-type)",
},
},
{
@@ -446,7 +446,7 @@ const theme: ThemeInput = {
"punctuation.separator.namespace.ruby",
],
settings: {
foreground: "var(--theme-syntax-type)",
foreground: "var(--syntax-type)",
},
},
{
@@ -460,7 +460,7 @@ const theme: ThemeInput = {
"entity.name.operator",
],
settings: {
foreground: "var(--theme-syntax-operator)",
foreground: "var(--syntax-operator)",
},
},
{
@@ -472,19 +472,19 @@ const theme: ThemeInput = {
"constant.other.placeholder",
],
settings: {
foreground: "var(--theme-syntax-variable)",
foreground: "var(--syntax-variable)",
},
},
{
scope: ["variable.other.constant", "variable.other.enummember"],
settings: {
foreground: "var(--theme-syntax-variable)",
foreground: "var(--syntax-variable)",
},
},
{
scope: ["meta.object-literal.key"],
settings: {
foreground: "var(--theme-syntax-variable)",
foreground: "var(--syntax-variable)",
},
},
{
@@ -498,7 +498,7 @@ const theme: ThemeInput = {
"support.constant.color",
],
settings: {
foreground: "var(--theme-syntax-string)",
foreground: "var(--syntax-string)",
},
},
{
@@ -512,7 +512,7 @@ const theme: ThemeInput = {
"support.other.parenthesis.regexp",
],
settings: {
foreground: "var(--theme-syntax-string)",
foreground: "var(--syntax-string)",
},
},
{
@@ -523,37 +523,37 @@ const theme: ThemeInput = {
"constant.character.set.regexp",
],
settings: {
foreground: "var(--theme-syntax-operator)",
foreground: "var(--syntax-operator)",
},
},
{
scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
settings: {
foreground: "var(--theme-syntax-operator)",
foreground: "var(--syntax-operator)",
},
},
{
scope: "keyword.operator.quantifier.regexp",
settings: {
foreground: "var(--theme-syntax-operator)",
foreground: "var(--syntax-operator)",
},
},
{
scope: ["constant.character", "constant.other.option"],
settings: {
foreground: "var(--theme-syntax-keyword)",
foreground: "var(--syntax-keyword)",
},
},
{
scope: "constant.character.escape",
settings: {
foreground: "var(--theme-syntax-operator)",
foreground: "var(--syntax-operator)",
},
},
{
scope: "entity.name.label",
settings: {
foreground: "var(--theme-text-muted)",
foreground: "var(--text-weak)",
},
},
],
@@ -565,18 +565,9 @@ const highlighter = await createHighlighter({
langs: [],
})
type ShikiContext = typeof highlighter
const ctx = createContext<ShikiContext>()
export function ShikiProvider(props: ParentProps) {
return <ctx.Provider value={highlighter}>{props.children}</ctx.Provider>
}
export function useShiki() {
const value = useContext(ctx)
if (!value) {
throw new Error("useShiki must be used within a ShikiProvider")
}
return value
}
export const { use: useShiki, provider: ShikiProvider } = createSimpleContext({
name: "Shiki",
init: () => {
return highlighter
},
})
+151 -159
View File
@@ -1,177 +1,169 @@
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js"
import { useSDK, useEvent } from "@/context"
import { createMemo } from "solid-js"
import { Binary } from "@/utils/binary"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
function init() {
const [store, setStore] = createStore<{
ready: boolean
provider: Provider[]
agent: Agent[]
project: Project
config: Config
path: Path
session: Session[]
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
node: FileNode[]
changes: File[]
}>({
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
ready: false,
agent: [],
provider: [],
session: [],
message: {},
part: {},
node: [],
changes: [],
})
const bus = useEvent()
bus.listen((event) => {
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
const [store, setStore] = createStore<{
ready: boolean
provider: Provider[]
agent: Agent[]
project: Project
config: Config
path: Path
session: Session[]
message: {
[sessionID: string]: Message[]
}
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
part: {
[messageID: string]: Part[]
}
case "message.part.updated": {
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
node: FileNode[]
changes: File[]
}>({
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
ready: false,
agent: [],
provider: [],
session: [],
message: {},
part: {},
node: [],
changes: [],
})
const sdk = useSDK()
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.part.updated": {
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
}
})
})
const sdk = useSDK()
const load = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!)),
provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
session: () =>
sdk.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
const load = {
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
session: () =>
sdk.client.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
),
),
),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
}
config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
}
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
const sanitize = (text: string) => text.replace(sanitizer(), "")
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
const sanitize = (text: string) => text.replace(sanitizer(), "")
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
return {
data: store,
set: setStore,
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
return {
data: store,
set: setStore,
get ready() {
return store.ready
},
async sync(sessionID: string) {
const [session, messages] = await Promise.all([
sdk.session.get({ path: { id: sessionID } }),
sdk.session.messages({ path: { id: sessionID } }),
])
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
draft.session[match.index] = session.data!
draft.message[sessionID] = messages
.data!.map((x) => x.info)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
}),
)
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
},
async sync(sessionID: string, isRetry = false) {
const [session, messages] = await Promise.all([
sdk.client.session.get({ path: { id: sessionID } }),
sdk.client.session.messages({ path: { id: sessionID } }),
])
// If no messages and this might be a new session, retry after a delay
if (!isRetry && messages.data!.length === 0) {
setTimeout(() => this.sync(sessionID, true), 500)
return
}
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
draft.session[match.index] = session.data!
draft.message[sessionID] = messages
.data!.map((x) => x.info)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
}),
)
},
},
},
load,
absolute,
sanitize,
}
}
type SyncContext = ReturnType<typeof init>
const ctx = createContext<SyncContext>()
export function SyncProvider(props: ParentProps) {
const value = init()
return (
<Show when={value.data.ready}>
<ctx.Provider value={value}>{props.children}</ctx.Provider>
</Show>
)
}
export function useSync() {
const value = useContext(ctx)
if (!value) {
throw new Error("useSync must be used within a SyncProvider")
}
return value
}
load,
absolute,
sanitize,
}
},
})
+21 -14
View File
@@ -3,10 +3,19 @@ import "@/index.css"
import { render } from "solid-js/web"
import { Router, Route } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { EventProvider, SDKProvider, SyncProvider, LocalProvider, ShikiProvider, MarkedProvider } from "@/context"
import { Fonts } from "@opencode-ai/ui"
import { ShikiProvider } from "./context/shiki"
import { MarkedProvider } from "./context/marked"
import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync"
import { LocalProvider } from "./context/local"
import Home from "@/pages"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
const url = new URLSearchParams(document.location.search).get("url") || `http://${host}:${port}`
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
@@ -18,19 +27,17 @@ render(
() => (
<ShikiProvider>
<MarkedProvider>
<SDKProvider>
<EventProvider>
<SyncProvider>
<LocalProvider>
<MetaProvider>
<Fonts />
<Router>
<Route path="/" component={Home} />
</Router>
</MetaProvider>
</LocalProvider>
</SyncProvider>
</EventProvider>
<SDKProvider url={url}>
<SyncProvider>
<LocalProvider>
<MetaProvider>
<Fonts />
<Router>
<Route path="/" component={Home} />
</Router>
</MetaProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</MarkedProvider>
</ShikiProvider>
+494 -54
View File
@@ -1,15 +1,27 @@
import { Button, Icon, List, SelectDialog, Tooltip } from "@opencode-ai/ui"
import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon, Accordion } from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
import EditorPane from "@/components/editor-pane"
import { For, onCleanup, onMount, Show } from "solid-js"
import { useSync, useSDK, useLocal } from "@/context"
import type { LocalFile, TextSelection } from "@/context/local"
import SessionTimeline from "@/components/session-timeline"
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
import { useLocal, type LocalFile, type TextSelection } from "@/context/local"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
import { ContentPart, PromptInput } from "@/components/prompt-input"
import { DateTime } from "luxon"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
closestCenter,
createSortable,
useDragDropContext,
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { JSX } from "solid-js"
import { Code } from "@/components/code"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Diff } from "@/components/diff"
export default function Page() {
const local = useLocal()
@@ -17,10 +29,18 @@ export default function Page() {
const sdk = useSDK()
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
modelSelectOpen: false,
fileSelectOpen: false,
})
let inputRef!: HTMLDivElement
let messageScrollElement!: HTMLDivElement
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
createEffect(() => {
if (!local.session.activeMessage()) return
if (!messageScrollElement) return
const element = messageScrollElement.querySelector(`[data-message="${local.session.activeMessage()?.id}"]`)
element?.scrollIntoView({ block: "start", behavior: "instant" })
})
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
@@ -35,7 +55,6 @@ export default function Page() {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault()
// TODO: command palette
return
}
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
@@ -101,15 +120,67 @@ export default function Page() {
}
}
const navigateChange = (dir: 1 | -1) => {
const active = local.file.active()
if (!active) return
const current = local.file.changeIndex(active.path)
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
local.file.setChangeIndex(active.path, next)
}
const handleTabChange = (path: string) => {
if (path === "chat" || path === "review") return
local.file.open(path)
}
const handleTabClose = (file: LocalFile) => {
local.file.close(file.path)
}
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setActiveItem(id)
}
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const currentFiles = local.file.opened().map((file) => file.path)
const fromIndex = currentFiles.indexOf(draggable.id.toString())
const toIndex = currentFiles.indexOf(droppable.id.toString())
if (fromIndex !== toIndex) {
local.file.move(draggable.id.toString(), toIndex)
}
}
}
const handleDragEnd = () => {
setActiveItem(undefined)
}
const scrollDiffItem = (element: HTMLElement) => {
element.scrollIntoView({ block: "start", behavior: "instant" })
}
const handleDiffTriggerClick = (event: MouseEvent) => {
const target = event.currentTarget as HTMLElement
queueMicrotask(() => {
if (target.getAttribute("aria-expanded") !== "true") return
const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
if (!item) return
scrollDiffItem(item)
})
}
const handlePromptSubmit = async (parts: ContentPart[]) => {
const existingSession = local.session.active()
let session = existingSession
if (!session) {
const created = await sdk.session.create()
const created = await sdk.client.session.create()
session = created.data ?? undefined
}
if (!session) return
local.session.setActive(session.id)
interface SubmissionAttachment {
path: string
@@ -187,7 +258,7 @@ export default function Page() {
}
})
await sdk.session.prompt({
await sdk.client.session.prompt({
path: { id: session.id },
body: {
agent: local.agent.current()!.name,
@@ -204,6 +275,7 @@ export default function Page() {
],
},
})
local.session.setActive(session.id)
}
const handleNewSession = () => {
@@ -211,67 +283,435 @@ export default function Page() {
inputRef?.focus()
}
const TabVisual = (props: { file: LocalFile }): JSX.Element => {
return (
<div class="flex items-center gap-x-1.5">
<FileIcon node={props.file} class="_grayscale-100" />
<span
classList={{
"text-14-medium": true,
"text-primary": !!props.file.status?.status,
italic: !props.file.pinned,
}}
>
{props.file.name}
</span>
<span class="hidden opacity-70">
<Switch>
<Match when={props.file.status?.status === "modified"}>
<span class="text-primary">M</span>
</Match>
<Match when={props.file.status?.status === "added"}>
<span class="text-success">A</span>
</Match>
<Match when={props.file.status?.status === "deleted"}>
<span class="text-error">D</span>
</Match>
</Switch>
</span>
</div>
)
}
const SortableTab = (props: {
file: LocalFile
onTabClick: (file: LocalFile) => void
onTabClose: (file: LocalFile) => void
}): JSX.Element => {
const sortable = createSortable(props.file.path)
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<Tooltip value={props.file.path} placement="bottom" class="h-full">
<div class="relative h-full">
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
<TabVisual file={props.file} />
</Tabs.Trigger>
<IconButton
icon="close"
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
variant="ghost"
onClick={() => props.onTabClose(props.file)}
/>
</div>
</Tooltip>
</div>
)
}
const ConstrainDragYAxis = (): JSX.Element => {
const context = useDragDropContext()
if (!context) return <></>
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = {
id: "constrain-y-axis",
order: 100,
callback: (transform) => ({ ...transform, y: 0 }),
}
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return <></>
}
const getDraggableId = (event: unknown): string | undefined => {
if (typeof event !== "object" || event === null) return undefined
if (!("draggable" in event)) return undefined
const draggable = (event as { draggable?: { id?: unknown } }).draggable
if (!draggable) return undefined
return typeof draggable.id === "string" ? draggable.id : undefined
}
return (
<div class="relative h-screen flex flex-col">
<header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
<main class="h-[calc(100vh-0rem)] flex">
<div class="shrink-0 w-70 p-1.5 bg-background-weak border-r border-border-weak-base flex flex-col items-start gap-1.5">
<div class="flex flex-col items-start self-stretch px-3 py-1">
<span class="text-12-medium overflow-hidden text-ellipsis">{sync.data.path.directory}</span>
<div class="w-70 shrink-0 bg-background-weak border-r border-border-weak-base flex flex-col items-start">
<div class="h-10 flex items-center self-stretch px-5 border-b border-border-weak-base">
<span class="text-14-regular overflow-hidden text-ellipsis">{getFilename(sync.data.path.directory)}</span>
</div>
<div class="flex flex-col items-start gap-4 self-stretch flex-1">
<div class="px-3 py-1.5 w-full">
<Button class="w-full" size="large" onClick={handleNewSession}>
<Icon name="plus" />
New Session
</Button>
</div>
<div class="flex flex-col items-start gap-4 self-stretch flex-1 py-4 px-3">
<Button class="w-full" size="large" onClick={handleNewSession} icon="edit-small-2">
New Session
</Button>
<List
data={sync.data.session}
key={(x) => x.id}
current={local.session.active()}
onSelect={(s) => local.session.setActive(s?.id)}
onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)}
>
{(session) => (
<Tooltip placement="right" value={session.title}>
<div>
<div class="flex items-center self-stretch gap-6">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
{session.title}
</span>
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
{DateTime.fromMillis(session.time.updated).toRelative()}
</span>
</div>
<div class="flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">2 files changed</span>
<div class="flex gap-2 justify-end items-center">
<span class="text-12-mono text-right text-text-diff-add-base">+43</span>
<span class="text-12-mono text-right text-text-diff-delete-base">-2</span>
{(session) => {
const diffs = createMemo(() => session.summary?.diffs ?? [])
const filesChanged = createMemo(() => diffs().length)
const additions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.additions ?? 0), 0))
const deletions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.deletions ?? 0), 0))
return (
<Tooltip placement="right" value={session.title}>
<div>
<div class="flex items-center self-stretch gap-6 justify-between">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
{session.title}
</span>
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
{DateTime.fromMillis(session.time.updated).toRelative()}
</span>
</div>
<div class="flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
<Show when={additions() || deletions()}>
<div class="flex gap-2 justify-end items-center">
<span class="text-12-mono text-right text-text-diff-add-base">{`+${additions()}`}</span>
<span class="text-12-mono text-right text-text-diff-delete-base">{`-${deletions()}`}</span>
</div>
</Show>
</div>
</div>
</div>
</Tooltip>
)}
</Tooltip>
)
}}
</List>
</div>
</div>
<div class="relative grid grid-cols-2 bg-background-base w-full">
<div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center">
<Show when={local.session.active()}>
{(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />}
</Show>
</div>
<div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar">
<Show when={local.session.active()}>
<EditorPane onFileClick={handleFileClick} />
</Show>
</div>
<div class="relative bg-background-base w-full h-full overflow-x-hidden">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs onChange={handleTabChange}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat" class="flex gap-x-1.5 items-center">
<div>Chat</div>
<Show when={local.session.active()}>
<div class="flex flex-col h-4 px-2 -mr-2 justify-center items-center rounded-full bg-surface-base text-12-medium text-text-strong">
{local.session.context()}%
</div>
</Show>
</Tabs.Trigger>
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
<For each={local.file.opened()}>
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => setStore("fileSelectOpen", true)}
/>
</div>
</Tabs.List>
<div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
<Show when={local.file.active() && local.file.active()!.content?.diff}>
{(() => {
const activeFile = local.file.active()!
const view = local.file.view(activeFile.path)
return (
<div class="flex items-center gap-1">
<Show when={view !== "raw"}>
<div class="mr-1 flex items-center gap-1">
<Tooltip value="Previous change" placement="bottom">
<IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
</Tooltip>
<Tooltip value="Next change" placement="bottom">
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
</Tooltip>
</div>
</Show>
<Tooltip value="Raw" placement="bottom">
<IconButton
icon="file-text"
variant="ghost"
classList={{
"text-text": view === "raw",
"text-text-muted/70": view !== "raw",
"bg-background-element": view === "raw",
}}
onClick={() => local.file.setView(activeFile.path, "raw")}
/>
</Tooltip>
<Tooltip value="Unified diff" placement="bottom">
<IconButton
icon="checklist"
variant="ghost"
classList={{
"text-text": view === "diff-unified",
"text-text-muted/70": view !== "diff-unified",
"bg-background-element": view === "diff-unified",
}}
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
/>
</Tooltip>
<Tooltip value="Split diff" placement="bottom">
<IconButton
icon="columns"
variant="ghost"
classList={{
"text-text": view === "diff-split",
"text-text-muted/70": view !== "diff-split",
"bg-background-element": view === "diff-split",
}}
onClick={() => local.file.setView(activeFile.path, "diff-split")}
/>
</Tooltip>
</div>
)
})()}
</Show>
</div>
</div>
<Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0">
<div class="p-6 pt-12 max-w-[904px] w-full mx-auto flex flex-col flex-1 min-h-0">
<Show
when={local.session.active()}
fallback={
<div class="flex flex-col pb-36 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
<div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)}/
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
</div>
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(sync.data.project.time.created).toRelative()}
</span>
</div>
</div>
</div>
}
>
{(activeSession) => (
<div class="py-3 flex flex-col flex-1 min-h-0">
<div class="flex items-start gap-8 flex-1 min-h-0">
<Show when={local.session.userMessages().length > 1}>
<ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1">
<For each={local.session.userMessages()}>
{(message) => (
<li
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
onClick={() => local.session.setActiveMessage(message.id)}
>
<div class="w-[18px] shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
<g>
<rect x="0" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="4" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="8" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="12" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="16" width="2" height="12" rx="1" fill="#CFCECD" />
</g>
</svg>
</div>
<div
data-active={local.session.activeMessage()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
{message.summary?.title ?? local.session.getMessageText(message)}
</div>
</li>
)}
</For>
</ul>
</Show>
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
<div class="flex flex-col items-start gap-50 pb-[800px]">
<For each={local.session.userMessages()}>
{(message) => {
const title = createMemo(() => message.summary?.title)
const prompt = createMemo(() => local.session.getMessageText(message))
const summary = createMemo(() => message.summary?.body)
return (
<div
data-message={message.id}
class="flex flex-col items-start self-stretch gap-14 pt-1.5"
>
{/* Title */}
<div class="flex flex-col items-start gap-2 self-stretch">
<h1 class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
{title() ?? prompt()}
</h1>
<Show when={title}>
<div class="text-12-regular text-text-base">{prompt()}</div>
</Show>
</div>
{/* Summary */}
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<Show when={summary}>
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">Summary</h2>
<div class="text-14-regular text-text-base self-stretch">{summary()}</div>
</div>
</Show>
<Show when={message.summary?.diffs.length}>
<Accordion class="w-full" multiple>
<For each={message.summary?.diffs || []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<Accordion.Header>
<Accordion.Trigger onClick={handleDiffTriggerClick}>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-5">
<FileIcon
node={{ path: diff.file, type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex">
<Show when={diff.file.includes("/")}>
<span class="text-text-base">
{getDirectory(diff.file)}/
</span>
</Show>
<span class="text-text-strong">
{getFilename(diff.file)}
</span>
</div>
</div>
<div class="flex gap-4 items-center justify-end">
<div class="flex gap-2 justify-end items-center">
<span class="text-12-mono text-right text-text-diff-add-base">{`+${diff.additions}`}</span>
<span class="text-12-mono text-right text-text-diff-delete-base">{`-${diff.deletions}`}</span>
</div>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<Diff
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</Show>
</div>
{/* Response */}
<div data-todo="Response (Timeline)">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">Response</h2>
</div>
</div>
</div>
)
}}
</For>
</div>
</div>
</div>
</div>
)}
</Show>
</div>
</Tabs.Content>
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
<For each={local.file.opened()}>
{(file) => (
<Tabs.Content value={file.path} class="select-text">
{(() => {
const view = local.file.view(file.path)
const showRaw = view === "raw" || !file.content?.diff
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
})()}
</Tabs.Content>
)}
</For>
</Tabs>
<DragOverlay>
{(() => {
const id = activeItem()
if (!id) return null
const draggedFile = local.file.node(id)
if (!draggedFile) return null
return (
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
<TabVisual file={draggedFile} />
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
<div
classList={{
"absolute inset-x-0 px-8 flex flex-col justify-center items-center z-50": true,
"bottom-8": !!local.session.active(),
"bottom-1/2 translate-y-1/2": !local.session.active(),
"absolute inset-x-0 px-6 max-w-[904px] flex flex-col justify-center items-center z-50 mx-auto": true,
"bottom-8": true,
// "bottom-8": !!local.session.active(),
// "bottom-1/2 translate-y-1/2": !local.session.active(),
}}
>
<PromptInput
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "0.15.15",
"version": "0.15.18",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.15.15",
"version": "0.15.18",
"name": "opencode",
"type": "module",
"private": true,
@@ -46,6 +46,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
+541 -158
View File
@@ -1,204 +1,587 @@
import type {
Agent,
Agent as ACPAgent,
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
CancelNotification,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
LoadSessionResponse,
NewSessionRequest,
NewSessionResponse,
PermissionOption,
PromptRequest,
PromptResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
import { SessionPrompt } from "../session/prompt"
import { Identifier } from "../id/id"
import { Installation } from "@/installation"
import { SessionLock } from "@/session/lock"
import { Bus } from "@/bus"
import { MessageV2 } from "@/session/message-v2"
import { Storage } from "@/storage/storage"
import { Command } from "@/command"
import { Agent as Agents } from "@/agent/agent"
import { Permission } from "@/permission"
export class OpenCodeAgent implements Agent {
private log = Log.create({ service: "acp-agent" })
private sessionManager = new ACPSessionManager()
private connection: AgentSideConnection
private config: ACPConfig
export namespace ACP {
const log = Log.create({ service: "acp-agent" })
constructor(connection: AgentSideConnection, config: ACPConfig = {}) {
this.connection = connection
this.config = config
}
// TODO: mcp servers?
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
this.log.info("initialize", { protocolVersion: params.protocolVersion })
type ToolKind =
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "switch_mode"
| "other"
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: false,
},
_meta: {
opencode: {
version: await import("../installation").then((m) => m.Installation.VERSION),
export class Agent implements ACPAgent {
private sessionManager = new ACPSessionManager()
private connection: AgentSideConnection
private config: ACPConfig
constructor(connection: AgentSideConnection, config: ACPConfig = {}) {
this.connection = connection
this.config = config
this.setupEventSubscriptions()
}
private setupEventSubscriptions() {
const options: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
Bus.subscribe(Permission.Event.Updated, async (event) => {
const acpSession = this.sessionManager.get(event.properties.sessionID)
if (!acpSession) return
try {
const permission = event.properties
const res = await this.connection
.requestPermission({
sessionId: acpSession.id,
toolCall: {
toolCallId: permission.callID ?? permission.id,
status: "pending",
title: permission.title,
rawInput: permission.metadata,
kind: toToolKind(permission.type),
locations: toLocations(permission.type, permission.metadata),
},
options,
})
.catch((error) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
Permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "reject",
})
return
})
if (!res) return
if (res.outcome.outcome !== "selected") {
Permission.respond({ sessionID: permission.sessionID, permissionID: permission.id, response: "reject" })
return
}
Permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: res.outcome.optionId as "once" | "always" | "reject",
})
} catch (err) {
if (!(err instanceof Permission.RejectedError)) {
log.error("unexpected error when handling permission", { error: err })
throw err
}
}
})
Bus.subscribe(MessageV2.Event.PartUpdated, async (event) => {
const props = event.properties
const { part } = props
const acpSession = this.sessionManager.get(part.sessionID)
if (!acpSession) return
const message = await Storage.read<MessageV2.Info>(["message", part.sessionID, part.messageID]).catch(
() => undefined,
)
if (!message || message.role !== "assistant") return
if (part.type === "tool") {
switch (part.state.status) {
case "pending":
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "tool_call",
toolCallId: part.callID,
title: part.tool,
kind: toToolKind(part.tool),
status: "pending",
locations: [],
rawInput: {},
},
})
.catch((err) => {
log.error("failed to send tool pending to ACP", { error: err })
})
break
case "running":
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
})
.catch((err) => {
log.error("failed to send tool in_progress to ACP", { error: err })
})
break
case "completed":
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "completed",
content: [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
],
title: part.state.title,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
},
},
})
.catch((err) => {
log.error("failed to send tool completed to ACP", { error: err })
})
break
case "error":
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "failed",
content: [
{
type: "content",
content: {
type: "text",
text: part.state.error,
},
},
],
rawOutput: {
error: part.state.error,
},
},
})
.catch((err) => {
log.error("failed to send tool error to ACP", { error: err })
})
break
}
} else if (part.type === "text") {
const delta = props.delta
if (delta && part.synthetic !== true) {
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((err) => {
log.error("failed to send text to ACP", { error: err })
})
}
} else if (part.type === "reasoning") {
const delta = props.delta
if (delta) {
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "agent_thought_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((err) => {
log.error("failed to send reasoning to ACP", { error: err })
})
}
}
})
}
async initialize(params: InitializeRequest) {
log.info("initialize", { protocolVersion: params.protocolVersion })
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
// TODO: map acp mcp
// mcpCapabilities: {
// http: true,
// sse: true,
// },
},
},
authMethods: [
{
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
id: "opencode-login",
},
],
_meta: {
opencode: {
version: Installation.VERSION,
},
},
}
}
async authenticate(_params: AuthenticateRequest) {
throw new Error("Authentication not implemented")
}
async newSession(params: NewSessionRequest) {
const model = await defaultModel(this.config)
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
const load = await this.loadSession({
cwd: params.cwd,
mcpServers: params.mcpServers,
sessionId: session.id,
})
return {
sessionId: session.id,
models: load.models,
modes: load.modes,
_meta: {},
}
}
async loadSession(params: LoadSessionRequest) {
const model = await defaultModel(this.config)
const sessionId = params.sessionId
const providers = await Provider.list()
const entries = Object.entries(providers).sort((a, b) => {
const nameA = a[1].info.name.toLowerCase()
const nameB = b[1].info.name.toLowerCase()
if (nameA < nameB) return -1
if (nameA > nameB) return 1
return 0
})
const availableModels = entries.flatMap(([providerID, provider]) => {
const models = Provider.sort(Object.values(provider.info.models))
return models.map((model) => ({
modelId: `${providerID}/${model.id}`,
name: `${provider.info.name}/${model.name}`,
}))
})
const availableCommands = (await Command.list()).map((command) => ({
name: command.name,
description: command.description ?? "",
}))
setTimeout(() => {
this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands,
},
})
}, 0)
const availableModes = (await Agents.list())
.filter((agent) => agent.mode !== "subagent")
.map((agent) => ({
id: agent.name,
name: agent.name,
description: agent.description,
}))
const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
return {
sessionId,
models: {
currentModelId: `${model.providerID}/${model.modelID}`,
availableModels,
},
modes: {
availableModes,
currentModeId,
},
_meta: {},
}
}
async setSessionModel(params: SetSessionModelRequest) {
const session = this.sessionManager.get(params.sessionId)
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`)
}
const parsed = Provider.parseModel(params.modelId)
const model = await Provider.getModel(parsed.providerID, parsed.modelID)
this.sessionManager.setModel(session.id, {
providerID: model.providerID,
modelID: model.modelID,
})
return {
_meta: {},
}
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
const session = this.sessionManager.get(params.sessionId)
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`)
}
await Agents.get(params.modeId).then((agent) => {
if (!agent) throw new Error(`Agent not found: ${params.modeId}`)
})
this.sessionManager.setMode(params.sessionId, params.modeId)
}
async prompt(params: PromptRequest) {
const sessionID = params.sessionId
const acpSession = this.sessionManager.get(sessionID)
if (!acpSession) {
throw new Error(`Session not found: ${sessionID}`)
}
const current = acpSession.model
const model = current ?? (await defaultModel(this.config))
if (!current) {
this.sessionManager.setModel(acpSession.id, model)
}
const agent = acpSession.modeId ?? "build"
const parts: SessionPrompt.PromptInput["parts"] = []
for (const part of params.prompt) {
switch (part.type) {
case "text":
parts.push({
type: "text" as const,
text: part.text,
})
break
case "image":
if (part.data) {
parts.push({
type: "file",
url: `data:${part.mimeType};base64,${part.data}`,
mime: part.mimeType,
})
} else if (part.uri && part.uri.startsWith("http:")) {
parts.push({
type: "file",
url: part.uri,
mime: part.mimeType,
})
}
break
case "resource_link":
const parsed = parseUri(part.uri)
parts.push(parsed)
break
case "resource":
const resource = part.resource
if ("text" in resource) {
parts.push({
type: "text",
text: resource.text,
})
}
break
default:
break
}
}
log.info("parts", { parts })
const cmd = await (async () => {
const text = parts.filter((part) => part.type === "text").join("")
const match = text.match(/^\/(\w+)\s*(.*)$/)
if (!match) return
const [c, args] = match.slice(1)
const command = await Command.get(c)
if (!command) return
return { command, args }
})()
if (cmd) {
await SessionPrompt.command({
sessionID,
command: cmd.command.name,
arguments: cmd.args,
agent,
})
} else {
await SessionPrompt.prompt({
sessionID,
model: {
providerID: model.providerID,
modelID: model.modelID,
},
parts,
agent,
})
}
return {
stopReason: "end_turn" as const,
_meta: {},
}
}
async cancel(params: CancelNotification) {
SessionLock.abort(params.sessionId)
}
}
async authenticate(params: AuthenticateRequest): Promise<void | AuthenticateResponse> {
this.log.info("authenticate", { methodId: params.methodId })
throw new Error("Authentication not yet implemented")
}
function toToolKind(toolName: string): ToolKind {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "bash":
return "execute"
case "webfetch":
return "fetch"
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
this.log.info("newSession", { cwd: params.cwd, mcpServers: params.mcpServers.length })
case "edit":
case "patch":
case "write":
return "edit"
const model = await this.defaultModel()
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
const availableModels = await this.availableModels()
case "grep":
case "glob":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
return {
sessionId: session.id,
models: {
currentModelId: `${model.providerID}/${model.modelID}`,
availableModels,
},
_meta: {},
case "list":
case "read":
return "read"
default:
return "other"
}
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
this.log.info("loadSession", { sessionId: params.sessionId, cwd: params.cwd })
const defaultModel = await this.defaultModel()
const session = await this.sessionManager.load(params.sessionId, params.cwd, params.mcpServers, defaultModel)
const availableModels = await this.availableModels()
return {
models: {
currentModelId: `${session.model.providerID}/${session.model.modelID}`,
availableModels,
},
_meta: {},
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "read":
case "edit":
case "write":
return input["filePath"] ? [{ path: input["filePath"] }] : []
case "glob":
case "grep":
return input["path"] ? [{ path: input["path"] }] : []
case "bash":
return []
case "list":
return input["path"] ? [{ path: input["path"] }] : []
default:
return []
}
}
async setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse> {
this.log.info("setSessionModel", { sessionId: params.sessionId, modelId: params.modelId })
const session = this.sessionManager.get(params.sessionId)
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`)
}
const parsed = Provider.parseModel(params.modelId)
const model = await Provider.getModel(parsed.providerID, parsed.modelID)
this.sessionManager.setModel(session.id, {
providerID: model.providerID,
modelID: model.modelID,
})
return {
_meta: {},
}
}
private async defaultModel() {
const configured = this.config.defaultModel
async function defaultModel(config: ACPConfig) {
const configured = config.defaultModel
if (configured) return configured
return Provider.defaultModel()
}
private async availableModels() {
const providers = await Provider.list()
const entries = Object.entries(providers).sort((a, b) => {
const nameA = a[1].info.name.toLowerCase()
const nameB = b[1].info.name.toLowerCase()
if (nameA < nameB) return -1
if (nameA > nameB) return 1
return 0
})
return entries.flatMap(([providerID, provider]) => {
const models = Provider.sort(Object.values(provider.info.models))
return models.map((model) => ({
modelId: `${providerID}/${model.id}`,
name: `${provider.info.name}/${model.name}`,
}))
})
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
this.log.info("prompt", {
sessionId: params.sessionId,
promptLength: params.prompt.length,
})
const acpSession = this.sessionManager.get(params.sessionId)
if (!acpSession) {
throw new Error(`Session not found: ${params.sessionId}`)
}
const current = acpSession.model
const model = current ?? (await this.defaultModel())
if (!current) {
this.sessionManager.setModel(acpSession.id, model)
}
const parts = params.prompt.map((content) => {
if (content.type === "text") {
function parseUri(
uri: string,
): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
try {
if (uri.startsWith("file://")) {
const path = uri.slice(7)
const name = path.split("/").pop() || path
return {
type: "text" as const,
text: content.text,
type: "file",
url: uri,
filename: name,
mime: "text/plain",
}
}
if (content.type === "resource") {
const resource = content.resource
let text = ""
if ("text" in resource && typeof resource.text === "string") {
text = resource.text
}
return {
type: "text" as const,
text,
if (uri.startsWith("zed://")) {
const url = new URL(uri)
const path = url.searchParams.get("path")
if (path) {
const name = path.split("/").pop() || path
return {
type: "file",
url: `file://${path}`,
filename: name,
mime: "text/plain",
}
}
}
return {
type: "text" as const,
text: JSON.stringify(content),
type: "text",
text: uri,
}
} catch {
return {
type: "text",
text: uri,
}
})
await SessionPrompt.prompt({
sessionID: acpSession.openCodeSessionId,
messageID: Identifier.ascending("message"),
model: {
providerID: model.providerID,
modelID: model.modelID,
},
parts,
acpConnection: {
connection: this.connection,
sessionId: params.sessionId,
},
})
this.log.debug("prompt response completed")
// Streaming notifications are now handled during prompt execution
// No need to send final text chunk here
return {
stopReason: "end_turn",
_meta: {},
}
}
async cancel(params: CancelNotification): Promise<void> {
this.log.info("cancel", { sessionId: params.sessionId })
}
}
-85
View File
@@ -1,85 +0,0 @@
import type {
Client,
CreateTerminalRequest,
CreateTerminalResponse,
KillTerminalCommandRequest,
KillTerminalResponse,
ReadTextFileRequest,
ReadTextFileResponse,
ReleaseTerminalRequest,
ReleaseTerminalResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
TerminalOutputRequest,
TerminalOutputResponse,
WaitForTerminalExitRequest,
WaitForTerminalExitResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
export class ACPClient implements Client {
private log = Log.create({ service: "acp-client" })
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
this.log.debug("requestPermission", params)
const firstOption = params.options[0]
if (!firstOption) {
return { outcome: { outcome: "cancelled" } }
}
return {
outcome: {
outcome: "selected",
optionId: firstOption.optionId,
},
}
}
async sessionUpdate(params: SessionNotification): Promise<void> {
this.log.debug("sessionUpdate", { sessionId: params.sessionId })
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.log.debug("writeTextFile", { path: params.path })
await Bun.write(params.path, params.content)
return { _meta: {} }
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
this.log.debug("readTextFile", { path: params.path })
const file = Bun.file(params.path)
const exists = await file.exists()
if (!exists) {
throw new Error(`File not found: ${params.path}`)
}
const content = await file.text()
return { content, _meta: {} }
}
async createTerminal(params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
this.log.debug("createTerminal", params)
throw new Error("Terminal support not yet implemented")
}
async terminalOutput(params: TerminalOutputRequest): Promise<TerminalOutputResponse> {
this.log.debug("terminalOutput", params)
throw new Error("Terminal support not yet implemented")
}
async releaseTerminal(params: ReleaseTerminalRequest): Promise<void | ReleaseTerminalResponse> {
this.log.debug("releaseTerminal", params)
throw new Error("Terminal support not yet implemented")
}
async waitForTerminalExit(params: WaitForTerminalExitRequest): Promise<WaitForTerminalExitResponse> {
this.log.debug("waitForTerminalExit", params)
throw new Error("Terminal support not yet implemented")
}
async killTerminal(params: KillTerminalCommandRequest): Promise<void | KillTerminalResponse> {
this.log.debug("killTerminal", params)
throw new Error("Terminal support not yet implemented")
}
}
-53
View File
@@ -1,53 +0,0 @@
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { OpenCodeAgent } from "./agent"
export namespace ACPServer {
const log = Log.create({ service: "acp-server" })
export async function start() {
await Instance.provide({
directory: process.cwd(),
fn: async () => {
log.info("starting ACP server", { cwd: process.cwd() })
const stdout = new WritableStream({
write(chunk) {
process.stdout.write(chunk)
},
})
const stdin = new ReadableStream({
start(controller) {
process.stdin.on("data", (chunk) => {
controller.enqueue(new Uint8Array(chunk))
})
process.stdin.on("end", () => {
controller.close()
})
},
})
const stream = ndJsonStream(stdout, stdin)
new AgentSideConnection((conn) => {
return new OpenCodeAgent(conn)
}, stream)
await new Promise<void>((resolve) => {
process.on("SIGTERM", () => {
log.info("received SIGTERM")
resolve()
})
process.on("SIGINT", () => {
log.info("received SIGINT")
resolve()
})
})
log.info("ACP server stopped")
},
})
}
}
+15 -44
View File
@@ -7,20 +7,15 @@ import type { ACPSessionState } from "./types"
export class ACPSessionManager {
private sessions = new Map<string, ACPSessionState>()
async create(
cwd: string,
mcpServers: McpServer[],
model?: ACPSessionState["model"],
): Promise<ACPSessionState> {
const sessionId = `acp_${Identifier.ascending("session")}`
const openCodeSession = await Session.create({ title: `ACP Session ${sessionId}` })
async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise<ACPSessionState> {
const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` })
const sessionId = session.id
const resolvedModel = model ?? (await Provider.defaultModel())
const state: ACPSessionState = {
id: sessionId,
cwd,
mcpServers,
openCodeSessionId: openCodeSession.id,
createdAt: new Date(),
model: resolvedModel,
}
@@ -29,54 +24,22 @@ export class ACPSessionManager {
return state
}
get(sessionId: string): ACPSessionState | undefined {
get(sessionId: string) {
return this.sessions.get(sessionId)
}
async remove(sessionId: string): Promise<void> {
async remove(sessionId: string) {
const state = this.sessions.get(sessionId)
if (!state) return
await Session.remove(state.openCodeSessionId).catch(() => {})
await Session.remove(sessionId).catch(() => {})
this.sessions.delete(sessionId)
}
has(sessionId: string): boolean {
has(sessionId: string) {
return this.sessions.has(sessionId)
}
async load(
sessionId: string,
cwd: string,
mcpServers: McpServer[],
model?: ACPSessionState["model"],
): Promise<ACPSessionState> {
const existing = this.sessions.get(sessionId)
if (existing) {
if (!existing.model) {
const resolved = model ?? (await Provider.defaultModel())
existing.model = resolved
this.sessions.set(sessionId, existing)
}
return existing
}
const openCodeSession = await Session.create({ title: `ACP Session ${sessionId} (loaded)` })
const resolvedModel = model ?? (await Provider.defaultModel())
const state: ACPSessionState = {
id: sessionId,
cwd,
mcpServers,
openCodeSessionId: openCodeSession.id,
createdAt: new Date(),
model: resolvedModel,
}
this.sessions.set(sessionId, state)
return state
}
getModel(sessionId: string) {
const session = this.sessions.get(sessionId)
if (!session) return
@@ -90,4 +53,12 @@ export class ACPSessionManager {
this.sessions.set(sessionId, session)
return session
}
setMode(sessionId: string, modeId: string) {
const session = this.sessions.get(sessionId)
if (!session) return
session.modeId = modeId
this.sessions.set(sessionId, session)
return session
}
}
+1 -1
View File
@@ -4,12 +4,12 @@ export interface ACPSessionState {
id: string
cwd: string
mcpServers: McpServer[]
openCodeSessionId: string
createdAt: Date
model: {
providerID: string
modelID: string
}
modeId?: string
}
export interface ACPConfig {
-6
View File
@@ -52,9 +52,6 @@ export namespace Agent {
{
edit: "deny",
bash: {
"awk -i inplace*": "ask",
"awk --inplace*": "ask",
"awk*": "allow",
"cut*": "allow",
"diff*": "allow",
"du*": "allow",
@@ -79,9 +76,6 @@ export namespace Agent {
"more*": "allow",
"pwd*": "allow",
"rg*": "allow",
"sed --in-place*": "ask",
"sed -i*": "ask",
"sed -n *": "allow",
"sort --output=*": "ask",
"sort -o *": "ask",
"sort*": "allow",
+46 -2
View File
@@ -1,5 +1,17 @@
import { ACPServer } from "../../acp/server"
import { Log } from "@/util/log"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent"
const log = Log.create({ service: "acp-command" })
process.on("unhandledRejection", (reason, promise) => {
log.error("Unhandled rejection", {
promise,
reason,
})
})
export const AcpCommand = cmd({
command: "acp",
@@ -13,6 +25,38 @@ export const AcpCommand = cmd({
},
handler: async (opts) => {
if (opts.cwd) process.chdir(opts["cwd"])
await ACPServer.start()
await bootstrap(process.cwd(), async () => {
const input = new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
process.stdout.write(Buffer.from(chunk), (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
},
})
const output = new ReadableStream<Uint8Array>({
start(controller) {
process.stdin.on("data", (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk))
})
process.stdin.on("end", () => controller.close())
process.stdin.on("error", (err) => controller.error(err))
},
})
const stream = ndJsonStream(input, output)
new AgentSideConnection((conn) => {
return new ACP.Agent(conn)
}, stream)
log.info("setup connection")
})
process.stdin.resume()
},
})
+1 -1
View File
@@ -157,7 +157,7 @@ export const TuiCommand = cmd({
;(async () => {
// if (Installation.isLocal()) return
const config = await Config.global()
const config = await Config.get()
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const latest = await Installation.latest().catch(() => {})
if (!latest) return
+131
View File
@@ -838,4 +838,135 @@ export namespace LSPServer {
}
},
}
export const LuaLS: Info = {
id: "lua-ls",
root: NearestRoot([
".luarc.json",
".luarc.jsonc",
".luacheckrc",
".stylua.toml",
"stylua.toml",
"selene.toml",
"selene.yml",
]),
extensions: [".lua"],
async spawn(root) {
let bin = Bun.which("lua-language-server", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("downloading lua-language-server from GitHub releases")
const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest")
if (!releaseResponse.ok) {
log.error("Failed to fetch lua-language-server release info")
return
}
const release = await releaseResponse.json()
const platform = process.platform
const arch = process.arch
let assetName = ""
let lualsArch: string = arch
if (arch === "arm64") lualsArch = "arm64"
else if (arch === "x64") lualsArch = "x64"
else if (arch === "ia32") lualsArch = "ia32"
let lualsPlatform: string = platform
if (platform === "darwin") lualsPlatform = "darwin"
else if (platform === "linux") lualsPlatform = "linux"
else if (platform === "win32") lualsPlatform = "win32"
const ext = platform === "win32" ? "zip" : "tar.gz"
assetName = `lua-language-server-${release.tag_name}-${lualsPlatform}-${lualsArch}.${ext}`
const supportedCombos = [
"darwin-arm64.tar.gz",
"darwin-x64.tar.gz",
"linux-x64.tar.gz",
"linux-arm64.tar.gz",
"win32-x64.zip",
"win32-ia32.zip",
]
const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}`
if (!supportedCombos.includes(assetSuffix)) {
log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`)
return
}
const asset = release.assets.find((a: any) => a.name === assetName)
if (!asset) {
log.error(`Could not find asset ${assetName} in latest lua-language-server release`)
return
}
const downloadUrl = asset.browser_download_url
const downloadResponse = await fetch(downloadUrl)
if (!downloadResponse.ok) {
log.error("Failed to download lua-language-server")
return
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
// Unlike zls which is a single self-contained binary,
// lua-language-server needs supporting files (meta/, locale/, etc.)
// Extract entire archive to dedicated directory to preserve all files
const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`)
// Remove old installation if exists
const stats = await fs.stat(installDir).catch(() => undefined)
if (stats) {
await fs.rm(installDir, { force: true, recursive: true })
}
await fs.mkdir(installDir, { recursive: true })
if (ext === "zip") {
const ok = await $`unzip -o -q ${tempPath} -d ${installDir}`.quiet().catch((error) => {
log.error("Failed to extract lua-language-server archive", { error })
})
if (!ok) return
} else {
const ok = await $`tar -xzf ${tempPath} -C ${installDir}`.quiet().catch((error) => {
log.error("Failed to extract lua-language-server archive", { error })
})
if (!ok) return
}
await fs.rm(tempPath, { force: true })
// Binary is located in bin/ subdirectory within the extracted archive
bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
log.error("Failed to extract lua-language-server binary")
return
}
if (platform !== "win32") {
const ok = await $`chmod +x ${bin}`.quiet().catch((error) => {
log.error("Failed to set executable permission for lua-language-server binary", { error })
})
if (!ok) return
}
log.info(`installed lua-language-server`, { bin })
}
return {
process: spawn(bin, {
cwd: root,
}),
}
},
}
}
+2 -2
View File
@@ -422,14 +422,14 @@ export namespace Provider {
const modPath =
provider.id === "google-vertex-anthropic" ? `${installedPath}/dist/anthropic/index.mjs` : installedPath
const mod = await import(modPath)
if (options["timeout"] !== undefined) {
if (options["timeout"] !== undefined && options["timeout"] !== null) {
// Only override fetch if user explicitly sets timeout
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
const { signal, ...rest } = init ?? {}
const signals: AbortSignal[] = []
if (signal) signals.push(signal)
signals.push(AbortSignal.timeout(options["timeout"]))
if (options["timeout"] !== false) signals.push(AbortSignal.timeout(options["timeout"]))
const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0]
+8 -2
View File
@@ -92,14 +92,16 @@ export namespace ProviderTransform {
}
if (modelID.includes("gpt-5") && !modelID.includes("gpt-5-chat")) {
result["reasoningEffort"] = "medium"
if (!modelID.includes("codex")) result["reasoningEffort"] = "medium"
if (providerID !== "azure") {
result["textVerbosity"] = modelID.includes("codex") ? "medium" : "low"
}
if (providerID === "opencode") {
result["promptCacheKey"] = sessionID
result["include"] = ["reasoning.encrypted_content"]
result["reasoningSummary"] = "detailed"
result["reasoningSummary"] = "auto"
}
}
return result
@@ -112,6 +114,10 @@ export namespace ProviderTransform {
return {
["openai" as string]: options,
}
case "@ai-sdk/amazon-bedrock":
return {
["bedrock" as string]: options,
}
case "@ai-sdk/anthropic":
return {
["anthropic" as string]: options,
+9 -5
View File
@@ -3,7 +3,7 @@ import { Bus } from "../bus"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { cors } from "hono/cors"
import { streamSSE } from "hono/streaming"
import { stream, streamSSE } from "hono/streaming"
import { Session } from "../session"
import z from "zod/v4"
import { Provider } from "../provider/provider"
@@ -811,10 +811,14 @@ export namespace Server {
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json")
const msg = await SessionPrompt.prompt({ ...body, sessionID })
return c.json(msg)
c.status(200)
c.header("Content-Type", "application/json")
return stream(c, async (stream) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json")
const msg = await SessionPrompt.prompt({ ...body, sessionID })
stream.write(JSON.stringify(msg))
})
},
)
.post(
+16 -1
View File
@@ -340,10 +340,25 @@ export namespace Session {
},
)
export const updatePart = fn(MessageV2.Part, async (part) => {
const UpdatePartInput = z.union([
MessageV2.Part,
z.object({
part: MessageV2.TextPart,
delta: z.string(),
}),
z.object({
part: MessageV2.ReasoningPart,
delta: z.string(),
}),
])
export const updatePart = fn(UpdatePartInput, async (input) => {
const part = "delta" in input ? input.part : input
const delta = "delta" in input ? input.delta : undefined
await Storage.write(["part", part.messageID, part.id], part)
Bus.publish(MessageV2.Event.PartUpdated, {
part,
delta,
})
return part
})
+4 -2
View File
@@ -6,7 +6,6 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag
import { Identifier } from "../id/id"
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
@@ -163,6 +162,7 @@ export namespace MessageV2 {
export const StepFinishPart = PartBase.extend({
type: z.literal("step-finish"),
reason: z.string(),
snapshot: z.string().optional(),
cost: z.number(),
tokens: z.object({
@@ -268,8 +268,9 @@ export namespace MessageV2 {
}),
summary: z
.object({
title: z.string().optional(),
body: z.string().optional(),
diffs: Snapshot.FileDiff.array(),
text: z.string(),
})
.optional(),
}).meta({
@@ -358,6 +359,7 @@ export namespace MessageV2 {
"message.part.updated",
z.object({
part: Part,
delta: z.string().optional(),
}),
),
PartRemoved: Bus.event(
+51 -188
View File
@@ -94,18 +94,9 @@ export namespace SessionPrompt {
})
.optional(),
agent: z.string().optional(),
noReply: z.boolean().optional(),
system: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
/**
* ACP (Agent Client Protocol) connection details for streaming responses.
* When provided, enables real-time streaming and tool execution visibility.
*/
acpConnection: z
.object({
connection: z.any(), // AgentSideConnection - using any to avoid circular deps
sessionId: z.string(), // ACP session ID (different from opencode sessionID)
})
.optional(),
parts: z.array(
z.discriminatedUnion("type", [
MessageV2.TextPart.omit({
@@ -152,6 +143,11 @@ export namespace SessionPrompt {
const userMsg = await createUserMessage(input)
await Session.touch(input.sessionID)
// Early return for context-only messages (no AI inference)
if (input.noReply) {
return userMsg
}
if (isBusy(input.sessionID)) {
return new Promise((resolve) => {
const queue = state().queued.get(input.sessionID) ?? []
@@ -184,7 +180,6 @@ export namespace SessionPrompt {
agent: agent.name,
system,
abort: abort.signal,
acpConnection: input.acpConnection,
})
const tools = await resolveTools({
@@ -196,6 +191,28 @@ export namespace SessionPrompt {
processor,
})
// const permUnsub = (() => {
// const handled = new Set<string>()
// const options = [
// { optionId: "allow_once", kind: "allow_once", name: "Allow once" },
// { optionId: "allow_always", kind: "allow_always", name: "Always allow" },
// { optionId: "reject_once", kind: "reject_once", name: "Reject" },
// ]
// return Bus.subscribe(Permission.Event.Updated, async (event) => {
// const info = event.properties
// if (info.sessionID !== input.sessionID) return
// if (handled.has(info.id)) return
// handled.add(info.id)
// const toolCallId = info.callID ?? info.id
// const metadata = info.metadata ?? {}
// // TODO: emit permission event to bus for ACP to handle
// Permission.respond({ sessionID: info.sessionID, permissionID: info.id, response: "reject" })
// })
// })()
// await using _permSub = defer(() => {
// permUnsub?.()
// })
const params = await Plugin.trigger(
"chat.params",
{
@@ -227,7 +244,9 @@ export namespace SessionPrompt {
}),
(messages) => insertReminders({ messages, agent }),
)
if (step === 0)
step++
await processor.next(msgs.findLast((m) => m.info.role === "user")?.info.id!)
if (step === 1) {
ensureTitle({
session,
history: msgs,
@@ -235,8 +254,11 @@ export namespace SessionPrompt {
providerID: model.providerID,
modelID: model.info.id,
})
step++
await processor.next(msgs.findLast((m) => m.info.role === "user")?.info.id!)
SessionSummary.summarize({
sessionID: input.sessionID,
messageID: userMsg.info.id,
})
}
await using _ = defer(async () => {
await processor.end()
})
@@ -884,60 +906,6 @@ export namespace SessionPrompt {
return input.messages
}
/**
* Maps tool names to ACP tool kinds for consistent categorization.
* - read: Tools that read data (read, glob, grep, list, webfetch, docs)
* - edit: Tools that modify state (edit, write, bash)
* - other: All other tools (MCP tools, task, todowrite, etc.)
*/
function determineToolKind(toolName: string): "read" | "edit" | "other" {
const readTools = [
"read",
"glob",
"grep",
"list",
"webfetch",
"context7_resolve_library_id",
"context7_get_library_docs",
]
const editTools = ["edit", "write", "bash"]
if (readTools.includes(toolName.toLowerCase())) return "read"
if (editTools.includes(toolName.toLowerCase())) return "edit"
return "other"
}
/**
* Extracts file/directory locations from tool inputs for ACP notifications.
* Returns array of {path} objects that ACP clients can use for navigation.
*
* Examples:
* - read({filePath: "/foo/bar.ts"}) -> [{path: "/foo/bar.ts"}]
* - glob({pattern: "*.ts", path: "/src"}) -> [{path: "/src"}]
* - bash({command: "ls"}) -> [] (no file references)
*/
function extractLocations(toolName: string, input: Record<string, any>): { path: string }[] {
try {
switch (toolName.toLowerCase()) {
case "read":
case "edit":
case "write":
return input["filePath"] ? [{ path: input["filePath"] }] : []
case "glob":
case "grep":
return input["path"] ? [{ path: input["path"] }] : []
case "bash":
return []
case "list":
return input["path"] ? [{ path: input["path"] }] : []
default:
return []
}
} catch {
return []
}
}
export type Processor = Awaited<ReturnType<typeof createProcessor>>
async function createProcessor(input: {
sessionID: string
@@ -946,10 +914,6 @@ export namespace SessionPrompt {
system: string[]
agent: string
abort: AbortSignal
acpConnection?: {
connection: any
sessionId: string
}
}) {
const toolcalls: Record<string, MessageV2.ToolPart> = {}
let snapshot: string | undefined
@@ -1047,7 +1011,7 @@ export namespace SessionPrompt {
const part = reasoningMap[value.id]
part.text += value.text
if (value.providerMetadata) part.metadata = value.providerMetadata
if (part.text) await Session.updatePart(part)
if (part.text) await Session.updatePart({ part, delta: value.text })
}
break
@@ -1079,26 +1043,6 @@ export namespace SessionPrompt {
},
})
toolcalls[value.id] = part as MessageV2.ToolPart
// Notify ACP client of pending tool call
if (input.acpConnection) {
await input.acpConnection.connection
.sessionUpdate({
sessionId: input.acpConnection.sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: value.id,
title: value.toolName,
kind: determineToolKind(value.toolName),
status: "pending",
locations: [], // Will be populated when we have input
rawInput: {},
},
})
.catch((err: Error) => {
log.error("failed to send tool pending to ACP", { error: err })
})
}
break
case "tool-input-delta":
@@ -1123,24 +1067,6 @@ export namespace SessionPrompt {
metadata: value.providerMetadata,
})
toolcalls[value.toolCallId] = part as MessageV2.ToolPart
// Notify ACP client that tool is running
if (input.acpConnection) {
await input.acpConnection.connection
.sessionUpdate({
sessionId: input.acpConnection.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: value.toolCallId,
status: "in_progress",
locations: extractLocations(value.toolName, value.input),
rawInput: value.input,
},
})
.catch((err: Error) => {
log.error("failed to send tool in_progress to ACP", { error: err })
})
}
}
break
}
@@ -1163,32 +1089,6 @@ export namespace SessionPrompt {
},
})
// Notify ACP client that tool completed
if (input.acpConnection) {
await input.acpConnection.connection
.sessionUpdate({
sessionId: input.acpConnection.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: value.toolCallId,
status: "completed",
content: [
{
type: "content",
content: {
type: "text",
text: value.output.output,
},
},
],
rawOutput: value.output,
},
})
.catch((err: Error) => {
log.error("failed to send tool completed to ACP", { error: err })
})
}
delete toolcalls[value.toolCallId]
}
break
@@ -1211,34 +1111,6 @@ export namespace SessionPrompt {
},
})
// Notify ACP client of tool error
if (input.acpConnection) {
await input.acpConnection.connection
.sessionUpdate({
sessionId: input.acpConnection.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: value.toolCallId,
status: "failed",
content: [
{
type: "content",
content: {
type: "text",
text: `Error: ${(value.error as any).toString()}`,
},
},
],
rawOutput: {
error: (value.error as any).toString(),
},
},
})
.catch((err: Error) => {
log.error("failed to send tool error to ACP", { error: err })
})
}
if (value.error instanceof Permission.RejectedError) {
blocked = true
}
@@ -1270,6 +1142,7 @@ export namespace SessionPrompt {
assistantMsg.tokens = usage.tokens
await Session.updatePart({
id: Identifier.ascending("part"),
reason: value.finishReason,
snapshot: await Snapshot.track(),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
@@ -1295,7 +1168,6 @@ export namespace SessionPrompt {
SessionSummary.summarize({
sessionID: input.sessionID,
messageID: assistantMsg.parentID,
providerID: assistantMsg.modelID,
})
break
@@ -1317,26 +1189,11 @@ export namespace SessionPrompt {
if (currentText) {
currentText.text += value.text
if (value.providerMetadata) currentText.metadata = value.providerMetadata
if (currentText.text) await Session.updatePart(currentText)
// Send streaming chunk to ACP client
if (input.acpConnection && value.text) {
await input.acpConnection.connection
.sessionUpdate({
sessionId: input.acpConnection.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: value.text,
},
},
})
.catch((err: Error) => {
log.error("failed to send text delta to ACP", { error: err })
// Don't fail the whole request if ACP notification fails
})
}
if (currentText.text)
await Session.updatePart({
part: currentText,
delta: value.text,
})
}
break
@@ -1902,9 +1759,15 @@ export namespace SessionPrompt {
.then((result) => {
if (result.text)
return Session.update(input.session.id, (draft) => {
const cleaned = result.text.replace(/<think>[\s\S]*?<\/think>\s*/g, "").split("\n")[0]
const cleaned = result.text
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!cleaned) return
const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
draft.title = title.trim()
draft.title = title
})
})
.catch((error) => {
@@ -1,81 +1,24 @@
You are OpenCode, the best coding agent on the planet.
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
- /help: Get help with using opencode
- To give feedback, users should report the issue at https://github.com/sst/opencode/issues
If the user asks for help or wants to give feedback inform them of the following:
- ctrl+p to list available actions
- To give feedback, users should report the issue at
https://github.com/sst/opencode
When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs
# Tone and style
You should be concise, direct, and to the point.
You should be concise, direct, and to the point, while providing complete information and matching the level of detail you provide in your response with the level of complexity of the user's query or the work you have completed.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: 2 + 2
assistant: 4
</example>
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: Yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: How many golf balls fit inside a jetta?
assistant: 150000
</example>
<example>
user: what files are in the directory src/?
assistant: [runs ls and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface.
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
- Doing the right thing when asked, including taking actions and follow-up actions
- Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
# Professional objectivity
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
# Task Management
You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
@@ -87,7 +30,7 @@ Examples:
<example>
user: Run the build and fix any type errors
assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
- Run the build
- Fix any type errors
@@ -107,7 +50,6 @@ In the above example, the assistant completes all the tasks, including the 10 er
<example>
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
@@ -124,25 +66,32 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
</example>
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
-
- Use the TodoWrite tool to plan the task if required
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
# Tool usage policy
- When doing file search, prefer to use the Task tool in order to reduce context usage.
- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description.
- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls.
- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly.
<example>
user: Where are errors from the client handled?
assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly]
</example>
<example>
user: What is the codebase structure?
assistant: [Uses the Task tool]
</example>
# Code References
+50 -18
View File
@@ -2,18 +2,22 @@ import { Provider } from "@/provider/provider"
import { fn } from "@/util/fn"
import z from "zod"
import { Session } from "."
import { generateText } from "ai"
import { generateText, type ModelMessage } from "ai"
import { MessageV2 } from "./message-v2"
import { Flag } from "@/flag/flag"
import { Identifier } from "@/id/id"
import { Snapshot } from "@/snapshot"
import { ProviderTransform } from "@/provider/transform"
import { SystemPrompt } from "./system"
import { Log } from "@/util/log"
export namespace SessionSummary {
const log = Log.create({ service: "session.summary" })
export const summarize = fn(
z.object({
sessionID: z.string(),
messageID: z.string(),
providerID: z.string(),
}),
async (input) => {
const all = await Session.messages(input.sessionID)
@@ -37,27 +41,57 @@ export namespace SessionSummary {
const messages = input.messages.filter(
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
)
const userMsg = messages.find((m) => m.info.id === input.messageID)!
const msgWithParts = messages.find((m) => m.info.id === input.messageID)!
const userMsg = msgWithParts.info as MessageV2.User
const diffs = await computeDiff({ messages })
userMsg.info.summary = {
userMsg.summary = {
...userMsg.summary,
diffs,
text: "",
}
await Session.updateMessage(userMsg)
const assistantMsg = messages.find((m) => m.info.role === "assistant")!.info as MessageV2.Assistant
const small = await Provider.getSmallModel(assistantMsg.providerID)
if (!small) return
const textPart = msgWithParts.parts.find((p) => p.type === "text" && !p.synthetic) as MessageV2.TextPart
if (textPart && !userMsg.summary?.title) {
const result = await generateText({
maxOutputTokens: small.info.reasoning ? 1500 : 20,
providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, {}),
messages: [
...SystemPrompt.title(small.providerID).map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
{
role: "user" as const,
content: textPart?.text ?? "",
},
],
model: small.language,
})
log.info("title", { title: result.text })
userMsg.summary.title = result.text
await Session.updateMessage(userMsg)
}
if (
Flag.OPENCODE_EXPERIMENTAL_TURN_SUMMARY &&
messages.every((m) => m.info.role !== "assistant" || m.info.time.completed)
messages.some(
(m) =>
m.info.role === "assistant" && m.parts.some((p) => p.type === "step-finish" && p.reason !== "tool-calls"),
)
) {
const assistantMsg = messages.find((m) => m.info.role === "assistant")!.info as MessageV2.Assistant
const small = await Provider.getSmallModel(assistantMsg.providerID)
if (!small) return
const result = await generateText({
model: small.language,
maxOutputTokens: 100,
maxOutputTokens: 50,
messages: [
{
role: "user",
content: `
Summarize the following conversation into 2 sentences MAX explaining what the assistant did and why. Do not explain the user's input.
Summarize the following conversation into 2 sentences MAX explaining what the assistant did and why. Do not explain the user's input. Do not speak in the third person about the assistant.
<conversation>
${JSON.stringify(MessageV2.toModelMessage(messages))}
</conversation>
@@ -65,12 +99,10 @@ export namespace SessionSummary {
},
],
})
userMsg.info.summary = {
text: result.text,
diffs: [],
}
userMsg.summary.body = result.text
log.info("body", { body: result.text })
await Session.updateMessage(userMsg)
}
await Session.updateMessage(userMsg.info)
}
export const diff = fn(
+2 -2
View File
@@ -23,6 +23,7 @@ export namespace Storage {
const MIGRATIONS: Migration[] = [
async (dir) => {
const project = path.resolve(dir, "../project")
if (!fs.exists(project)) return
for await (const projectDir of new Bun.Glob("*").scan({
cwd: project,
onlyFiles: false,
@@ -177,8 +178,7 @@ export namespace Storage {
async function withErrorHandling<T>(body: () => Promise<T>) {
return body().catch((e) => {
if (!(e instanceof Error))
throw e
if (!(e instanceof Error)) throw e
const errnoException = e as NodeJS.ErrnoException
if (errnoException.code === "ENOENT") {
throw new NotFoundError({ message: `Resource not found: ${errnoException.path}` })
+1 -1
View File
@@ -621,6 +621,6 @@ export function replace(content: string, oldString: string, newString: string, r
throw new Error("oldString not found in content")
}
throw new Error(
"oldString found multiple times and requires more code context to uniquely identify the intended match",
"Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.",
)
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { $ } from "bun"
import { realpathSync } from "fs"
import os from "os"
import path from "path"
@@ -17,7 +18,7 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
await options?.dispose?.(dirpath)
await $`rm -rf ${dirpath}`.quiet()
},
path: dirpath,
path: realpathSync(dirpath),
extra: extra as T,
}
return result
-2
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { BashTool } from "../../src/tool/bash"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
const ctx = {
@@ -15,7 +14,6 @@ const ctx = {
const bash = await BashTool.init()
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
describe("tool.bash", () => {
test("basic", async () => {
+54 -57
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { PatchTool } from "../../src/tool/patch"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import * as fs from "fs/promises"
@@ -10,51 +9,46 @@ const ctx = {
sessionID: "test",
messageID: "",
toolCallID: "",
agent: "build",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
}
const patchTool = await PatchTool.init()
Log.init({ print: false })
describe("tool.patch", () => {
test("should validate required parameters", async () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
await expect(
patchTool.execute({ patchText: "" }, ctx)
).rejects.toThrow("patchText is required")
await expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
},
})
})
test("should validate patch format", async () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
await expect(
patchTool.execute({ patchText: "invalid patch" }, ctx)
).rejects.toThrow("Failed to parse patch")
await expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch")
},
})
})
test("should handle empty patch", async () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
const emptyPatch = `*** Begin Patch
*** End Patch`
await expect(
patchTool.execute({ patchText: emptyPatch }, ctx)
).rejects.toThrow("No file changes found in patch")
await expect(patchTool.execute({ patchText: emptyPatch }, ctx)).rejects.toThrow(
"No file changes found in patch",
)
},
})
})
test("should reject files outside working directory", async () => {
await Instance.provide({
directory: "/tmp",
@@ -63,17 +57,17 @@ describe("tool.patch", () => {
*** Add File: /etc/passwd
+malicious content
*** End Patch`
await expect(
patchTool.execute({ patchText: maliciousPatch }, ctx)
).rejects.toThrow("is not in the current working directory")
await expect(patchTool.execute({ patchText: maliciousPatch }, ctx)).rejects.toThrow(
"is not in the current working directory",
)
},
})
})
test("should handle simple add file operation", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
@@ -82,13 +76,13 @@ describe("tool.patch", () => {
+Hello World
+This is a test file
*** End Patch`
const result = await patchTool.execute({ patchText }, ctx)
expect(result.title).toContain("files changed")
expect(result.metadata.diff).toBeDefined()
expect(result.output).toContain("Patch applied successfully")
// Verify file was created
const filePath = path.join(fixture.path, "test-file.txt")
const content = await fs.readFile(filePath, "utf-8")
@@ -96,10 +90,10 @@ describe("tool.patch", () => {
},
})
})
test("should handle file with context update", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
@@ -109,24 +103,24 @@ describe("tool.patch", () => {
+const DEBUG = false
+const VERSION = "1.0"
*** End Patch`
const result = await patchTool.execute({ patchText }, ctx)
expect(result.title).toContain("files changed")
expect(result.metadata.diff).toBeDefined()
expect(result.output).toContain("Patch applied successfully")
// Verify file was created with correct content
const filePath = path.join(fixture.path, "config.js")
const content = await fs.readFile(filePath, "utf-8")
expect(content).toBe("const API_KEY = \"test-key\"\nconst DEBUG = false\nconst VERSION = \"1.0\"")
expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"')
},
})
})
test("should handle multiple file operations", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
@@ -138,13 +132,13 @@ describe("tool.patch", () => {
*** Add File: file3.txt
+Content of file 3
*** End Patch`
const result = await patchTool.execute({ patchText }, ctx)
expect(result.title).toContain("3 files changed")
expect(result.metadata.diff).toBeDefined()
expect(result.output).toContain("Patch applied successfully")
// Verify all files were created
for (let i = 1; i <= 3; i++) {
const filePath = path.join(fixture.path, `file${i}.txt`)
@@ -154,10 +148,10 @@ describe("tool.patch", () => {
},
})
})
test("should create parent directories when adding nested files", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
@@ -165,26 +159,29 @@ describe("tool.patch", () => {
*** Add File: deep/nested/file.txt
+Deep nested content
*** End Patch`
const result = await patchTool.execute({ patchText }, ctx)
expect(result.title).toContain("files changed")
expect(result.output).toContain("Patch applied successfully")
// Verify nested file was created
const nestedPath = path.join(fixture.path, "deep", "nested", "file.txt")
const exists = await fs.access(nestedPath).then(() => true).catch(() => false)
const exists = await fs
.access(nestedPath)
.then(() => true)
.catch(() => false)
expect(exists).toBe(true)
const content = await fs.readFile(nestedPath, "utf-8")
expect(content).toBe("Deep nested content")
},
})
})
test("should generate proper unified diff in metadata", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
@@ -195,9 +192,9 @@ describe("tool.patch", () => {
+line 2
+line 3
*** End Patch`
await patchTool.execute({ patchText: patchText1 }, ctx)
// Now create an update patch
const patchText2 = `*** Begin Patch
*** Update File: test.txt
@@ -207,9 +204,9 @@ describe("tool.patch", () => {
+line 2 updated
line 3
*** End Patch`
const result = await patchTool.execute({ patchText: patchText2 }, ctx)
expect(result.metadata.diff).toBeDefined()
expect(result.metadata.diff).toContain("@@")
expect(result.metadata.diff).toContain("-line 2")
@@ -217,10 +214,10 @@ describe("tool.patch", () => {
},
})
})
test("should handle complex patch with multiple operations", async () => {
await using fixture = await tmpdir()
await Instance.provide({
directory: fixture.path,
fn: async () => {
@@ -238,26 +235,26 @@ describe("tool.patch", () => {
+ "debug": true
+}
*** End Patch`
const result = await patchTool.execute({ patchText }, ctx)
expect(result.title).toContain("3 files changed")
expect(result.metadata.diff).toBeDefined()
expect(result.output).toContain("Patch applied successfully")
// Verify all files were created
const newPath = path.join(fixture.path, "new.txt")
const newContent = await fs.readFile(newPath, "utf-8")
expect(newContent).toBe("This is a new file\nwith multiple lines")
const existingPath = path.join(fixture.path, "existing.txt")
const existingContent = await fs.readFile(existingPath, "utf-8")
expect(existingContent).toBe("old content\nnew line\nmore content")
const configPath = path.join(fixture.path, "config.json")
const configContent = await fs.readFile(configPath, "utf-8")
expect(configContent).toBe('{\n "version": "1.0",\n "debug": true\n}')
},
})
})
})
})
@@ -0,0 +1,55 @@
import { test, expect } from "bun:test"
import { Wildcard } from "../../src/util/wildcard"
test("match handles glob tokens", () => {
expect(Wildcard.match("file1.txt", "file?.txt")).toBe(true)
expect(Wildcard.match("file12.txt", "file?.txt")).toBe(false)
expect(Wildcard.match("foo+bar", "foo+bar")).toBe(true)
})
test("all picks the most specific pattern", () => {
const rules = {
"*": "deny",
"git *": "ask",
"git status": "allow",
}
expect(Wildcard.all("git status", rules)).toBe("allow")
expect(Wildcard.all("git log", rules)).toBe("ask")
expect(Wildcard.all("echo hi", rules)).toBe("deny")
})
test("allStructured matches command sequences", () => {
const rules = {
"git *": "ask",
"git status*": "allow",
}
expect(Wildcard.allStructured({ head: "git", tail: ["status", "--short"] }, rules)).toBe("allow")
expect(Wildcard.allStructured({ head: "npm", tail: ["run", "build", "--watch"] }, { "npm run *": "allow" })).toBe(
"allow",
)
expect(Wildcard.allStructured({ head: "ls", tail: ["-la"] }, rules)).toBeUndefined()
})
test("allStructured prioritizes flag-specific patterns", () => {
const rules = {
"find *": "allow",
"find * -delete*": "ask",
"sort*": "allow",
"sort -o *": "ask",
}
expect(Wildcard.allStructured({ head: "find", tail: ["src", "-delete"] }, rules)).toBe("ask")
expect(Wildcard.allStructured({ head: "find", tail: ["src", "-print"] }, rules)).toBe("allow")
expect(Wildcard.allStructured({ head: "sort", tail: ["-o", "out.txt"] }, rules)).toBe("ask")
expect(Wildcard.allStructured({ head: "sort", tail: ["--reverse"] }, rules)).toBe("allow")
})
test("allStructured handles sed flags", () => {
const rules = {
"sed * -i*": "ask",
"sed -n*": "allow",
}
expect(Wildcard.allStructured({ head: "sed", tail: ["-i", "file"] }, rules)).toBe("ask")
expect(Wildcard.allStructured({ head: "sed", tail: ["-i.bak", "file"] }, rules)).toBe("ask")
expect(Wildcard.allStructured({ head: "sed", tail: ["-n", "1p", "file"] }, rules)).toBe("allow")
expect(Wildcard.allStructured({ head: "sed", tail: ["-i", "-n", "/./p", "myfile.txt"] }, rules)).toBe("ask")
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.15.15",
"version": "0.15.18",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.15.15",
"version": "0.15.18",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+5 -5
View File
@@ -602,8 +602,9 @@ export type UserMessage = {
created: number
}
summary?: {
title?: string
body?: string
diffs: Array<FileDiff>
text: string
}
}
@@ -835,6 +836,7 @@ export type StepFinishPart = {
sessionID: string
messageID: string
type: "step-finish"
reason: string
snapshot?: string
cost: number
tokens: {
@@ -1115,6 +1117,7 @@ export type EventMessagePartUpdated = {
type: "message.part.updated"
properties: {
part: Part
delta?: string
}
}
@@ -1891,14 +1894,11 @@ export type SessionPromptData = {
modelID: string
}
agent?: string
noReply?: boolean
system?: string
tools?: {
[key: string]: boolean
}
acpConnection?: {
connection: unknown
sessionId: string
}
parts: Array<TextPartInput | FilePartInput | AgentPartInput>
}
path: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "0.15.15",
"version": "0.15.18",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "0.15.15",
"version": "0.15.18",
"type": "module",
"exports": {
".": "./src/components/index.ts",
+147
View File
@@ -0,0 +1,147 @@
[data-component="accordion"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0px;
align-self: stretch;
[data-slot="accordion-item"] {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0px;
align-self: stretch;
border: 1px solid var(--border-weak-base);
border-bottom: none;
border-top: none;
overflow: clip;
[data-slot="accordion-header"] {
width: 100%;
display: flex;
align-items: center;
margin: 0;
padding: 0;
[data-slot="accordion-trigger"] {
width: 100%;
display: flex;
height: 40px;
padding: 8px 12px;
justify-content: space-between;
align-items: center;
align-self: stretch;
cursor: default;
user-select: none;
background-color: var(--surface-base);
border-bottom: 1px solid var(--border-weak-base);
overflow: clip;
color: var(--text-strong);
transition: background-color 0.15s ease;
/* text-12-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
&:hover {
background-color: var(--surface-base);
}
&:focus-visible {
outline: none;
}
&[data-disabled] {
cursor: not-allowed;
}
}
}
&[data-expanded] {
border: 1px solid var(--border-weak-base);
border-bottom: 1px solid var(--border-weak-base);
margin-top: 8px;
margin-bottom: 8px;
border-radius: 8px;
[data-slot="accordion-item"]:has(+ &) {
&[data-closed] {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
[data-slot="accordion-trigger"] {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
}
margin-bottom: 8px;
}
& + [data-slot="accordion-item"] {
border-top: 1px solid var(--border-weak-base);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-top: 8px;
}
}
&:first-child {
margin-top: 0px;
border-top: 1px solid var(--border-weak-base);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
&[data-closed] {
[data-slot="accordion-trigger"] {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
}
}
&:last-child {
margin-bottom: 0px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
&[data-closed] {
[data-slot="accordion-trigger"] {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
}
}
[data-slot="accordion-content"] {
overflow: hidden;
width: 100%;
/* animation: slideUp 250ms cubic-bezier(0.87, 0, 0.13, 1); */
/**/
/* &[data-expanded] { */
/* animation: slideDown 250ms cubic-bezier(0.87, 0, 0.13, 1); */
/* } */
}
}
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--kb-accordion-content-height);
}
}
@keyframes slideUp {
from {
height: var(--kb-accordion-content-height);
}
to {
height: 0;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { Accordion as Kobalte } from "@kobalte/core/accordion"
import { splitProps } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
export interface AccordionProps extends ComponentProps<typeof Kobalte> {}
export interface AccordionItemProps extends ComponentProps<typeof Kobalte.Item> {}
export interface AccordionHeaderProps extends ComponentProps<typeof Kobalte.Header> {}
export interface AccordionTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
export interface AccordionContentProps extends ComponentProps<typeof Kobalte.Content> {}
function AccordionRoot(props: AccordionProps) {
const [split, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte
{...rest}
data-component="accordion"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
/>
)
}
function AccordionItem(props: AccordionItemProps) {
const [split, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte.Item
{...rest}
data-slot="accordion-item"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
/>
)
}
function AccordionHeader(props: ParentProps<AccordionHeaderProps>) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Header
{...rest}
data-slot="accordion-header"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</Kobalte.Header>
)
}
function AccordionTrigger(props: ParentProps<AccordionTriggerProps>) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Trigger
{...rest}
data-slot="accordion-trigger"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</Kobalte.Trigger>
)
}
function AccordionContent(props: ParentProps<AccordionContentProps>) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Content
{...rest}
data-slot="accordion-content"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</Kobalte.Content>
)
}
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Header: AccordionHeader,
Trigger: AccordionTrigger,
Content: AccordionContent,
})
+14 -2
View File
@@ -45,6 +45,10 @@
border-color: var(--border-focus);
background-color: var(--surface-focus);
}
[data-slot="icon"] {
color: var(--icon-strong-base);
}
}
&[data-variant="ghost"] {
@@ -66,7 +70,10 @@
}
&[data-size="normal"] {
padding: 0 6px 0 6px;
padding: 0 6px;
&[data-icon] {
padding: 0 6px 0 4px;
}
font-size: var(--font-size-small);
line-height: var(--line-height-large);
@@ -75,7 +82,12 @@
&[data-size="large"] {
height: 32px;
padding: 0 8px 0 6px;
padding: 0 8px;
&[data-icon] {
padding: 0 8px 0 6px;
}
gap: 8px;
font-family: var(--font-family-sans);
+8 -2
View File
@@ -1,26 +1,32 @@
import { Button as Kobalte } from "@kobalte/core/button"
import { type ComponentProps, splitProps } from "solid-js"
import { type ComponentProps, Show, splitProps } from "solid-js"
import { Icon, IconProps } from "./icon"
export interface ButtonProps
extends ComponentProps<typeof Kobalte>,
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
size?: "normal" | "large"
variant?: "primary" | "secondary" | "ghost"
icon?: IconProps["name"]
}
export function Button(props: ButtonProps) {
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
const [split, rest] = splitProps(props, ["variant", "size", "icon", "class", "classList"])
return (
<Kobalte
{...rest}
data-component="button"
data-size={split.size || "normal"}
data-variant={split.variant || "secondary"}
data-icon={split.icon}
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
<Show when={split.icon}>
<Icon data-slot="icon" name={split.icon!} size="small" />
</Show>
{props.children}
</Kobalte>
)
@@ -0,0 +1,46 @@
[data-component="collapsible"] {
display: flex;
flex-direction: column;
[data-slot="trigger"] {
cursor: pointer;
user-select: none;
&:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
&[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
}
[data-slot="content"] {
overflow: hidden;
/* animation: slideUp 250ms ease-out; */
/* &[data-expanded] { */
/* animation: slideDown 250ms ease-out; */
/* } */
}
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--kb-collapsible-content-height);
}
}
@keyframes slideUp {
from {
height: var(--kb-collapsible-content-height);
}
to {
height: 0;
}
}
@@ -0,0 +1,34 @@
import { Collapsible as Kobalte, CollapsibleRootProps } from "@kobalte/core/collapsible"
import { ComponentProps, ParentProps, splitProps } from "solid-js"
export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> {
class?: string
classList?: ComponentProps<"div">["classList"]
}
function CollapsibleRoot(props: CollapsibleProps) {
const [local, others] = splitProps(props, ["class", "classList"])
return (
<Kobalte
data-component="collapsible"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
{...others}
/>
)
}
function CollapsibleTrigger(props: ComponentProps<typeof Kobalte.Trigger>) {
return <Kobalte.Trigger data-slot="trigger" {...props} />
}
function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) {
return <Kobalte.Content data-slot="content" {...props} />
}
export const Collapsible = Object.assign(CollapsibleRoot, {
Trigger: CollapsibleTrigger,
Content: CollapsibleContent,
})
+3 -2
View File
@@ -5,11 +5,12 @@ import { Icon, IconProps } from "./icon"
export interface IconButtonProps {
icon: IconProps["name"]
size?: "normal" | "large"
iconSize?: IconProps["size"]
variant?: "primary" | "secondary" | "ghost"
}
export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList"])
return (
<Kobalte
{...rest}
@@ -21,7 +22,7 @@ export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
[split.class ?? ""]: !!split.class,
}}
>
<Icon data-slot="icon" name={props.icon} size={split.size === "large" ? "normal" : "small"} />
<Icon data-slot="icon" name={props.icon} size={split.iconSize ?? (split.size === "large" ? "normal" : "small")} />
</Kobalte>
)
}
+2 -2
View File
@@ -18,8 +18,8 @@
}
&[data-size="large"] {
width: 32px;
height: 32px;
width: 24px;
height: 24px;
}
[data-slot="svg"] {
+4
View File
@@ -135,6 +135,10 @@ const newIcons = {
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
"arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`,
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
"edit-small-2": `<path d="M17.0834 17.0833V17.5833H17.5834V17.0833H17.0834ZM2.91675 17.0833H2.41675V17.5833H2.91675V17.0833ZM2.91675 2.91659V2.41659H2.41675V2.91659H2.91675ZM9.58341 3.41659H10.0834V2.41659H9.58341V2.91659V3.41659ZM17.5834 10.4166V9.91659H16.5834V10.4166H17.0834H17.5834ZM10.4167 7.08325L10.0632 6.7297L9.91675 6.87615V7.08325H10.4167ZM10.4167 9.58325H9.91675V10.0833H10.4167V9.58325ZM12.9167 9.58325V10.0833H13.1239L13.2703 9.93681L12.9167 9.58325ZM15.4167 2.08325L15.7703 1.7297L15.4167 1.37615L15.0632 1.7297L15.4167 2.08325ZM17.9167 4.58325L18.2703 4.93681L18.6239 4.58325L18.2703 4.2297L17.9167 4.58325ZM17.0834 17.0833V16.5833H2.91675V17.0833V17.5833H17.0834V17.0833ZM2.91675 17.0833H3.41675V2.91659H2.91675H2.41675V17.0833H2.91675ZM2.91675 2.91659V3.41659H9.58341V2.91659V2.41659H2.91675V2.91659ZM17.0834 10.4166H16.5834V17.0833H17.0834H17.5834V10.4166H17.0834ZM10.4167 7.08325H9.91675V9.58325H10.4167H10.9167V7.08325H10.4167ZM10.4167 9.58325V10.0833H12.9167V9.58325V9.08325H10.4167V9.58325ZM10.4167 7.08325L10.7703 7.43681L15.7703 2.43681L15.4167 2.08325L15.0632 1.7297L10.0632 6.7297L10.4167 7.08325ZM15.4167 2.08325L15.0632 2.43681L17.5632 4.93681L17.9167 4.58325L18.2703 4.2297L15.7703 1.7297L15.4167 2.08325ZM17.9167 4.58325L17.5632 4.2297L12.5632 9.2297L12.9167 9.58325L13.2703 9.93681L18.2703 4.93681L17.9167 4.58325Z" fill="currentColor"/>`,
folder: `<path d="M2.08301 2.91675V16.2501H17.9163V5.41675H9.99967L8.33301 2.91675H2.08301Z" stroke="currentColor" stroke-linecap="round"/>`,
"pencil-line": `<path d="M9.58301 17.9166H17.9163M17.9163 5.83325L14.1663 2.08325L2.08301 14.1666V17.9166H5.83301L17.9163 5.83325Z" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-grabber-vertical": `<path d="M6.66675 12.4998L10.0001 15.8332L13.3334 12.4998M6.66675 7.49984L10.0001 4.1665L13.3334 7.49984" stroke="currentColor" stroke-linecap="square"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
+2
View File
@@ -1,4 +1,6 @@
export * from "./accordion"
export * from "./button"
export * from "./collapsible"
export * from "./dialog"
export * from "./icon"
export * from "./icon-button"
+2 -4
View File
@@ -1,8 +1,5 @@
[data-component="list"] {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
/* overflow-y: auto; */
/* Hide scrollbar */
&::-webkit-scrollbar {
@@ -13,6 +10,7 @@
[data-slot="item"] {
width: 100%;
margin-bottom: 6px;
padding: 4px 12px;
text-align: left;
+4
View File
@@ -23,6 +23,10 @@ export function List<T>(props: ListProps<T>) {
initialActive: props.current ? props.key(props.current) : undefined,
loop: true,
})
createEffect(() => {
if (props.current) list.setActive(props.key(props.current))
})
// const resetSelection = () => {
// if (props.data.length === 0) return
// list.setActive(props.key(props.data[0]))
+13 -11
View File
@@ -3,14 +3,11 @@
height: 100%;
display: flex;
flex-direction: column;
border-width: 1px;
border-style: solid;
border-radius: var(--radius-sm);
border-color: var(--border-weak-base);
background-color: var(--background-stronger);
overflow: clip;
[data-slot="list"] {
height: 40px;
width: 100%;
position: relative;
display: flex;
@@ -32,7 +29,6 @@
height: 100%;
border-bottom: 1px solid var(--border-weak-base);
background-color: var(--background-base);
border-top-right-radius: var(--radius-sm);
}
&:empty::after {
@@ -42,19 +38,25 @@
[data-slot="trigger"] {
position: relative;
height: 36px;
padding: 8px 12px;
height: 100%;
padding: 8px 24px;
display: flex;
align-items: center;
font-size: var(--text-sm);
color: var(--text-base);
/* text-14-medium */
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
color: var(--text-weak);
line-height: var(--line-height-large); /* 142.857% */
letter-spacing: var(--letter-spacing-normal);
white-space: nowrap;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
border-right: 1px solid var(--border-weak-base);
background-color: var(--background-weak);
background-color: var(--background-base);
transition:
background-color 0.15s ease,
color 0.15s ease;
@@ -68,7 +70,7 @@
box-shadow: 0 0 0 2px var(--border-focus);
}
&[data-selected] {
color: var(--text-base);
color: var(--text-strong);
background-color: transparent;
border-bottom-color: transparent;
}
+137 -4
View File
@@ -1,8 +1,27 @@
import type { Component } from "solid-js"
import { Button, Select, Tabs, Tooltip, Fonts, List } from "./components"
import { createSignal } from "solid-js"
import {
Accordion,
Button,
Select,
Tabs,
Tooltip,
Fonts,
List,
Dialog,
Icon,
IconButton,
Input,
SelectDialog,
Collapsible,
} from "./components"
import "./index.css"
const Demo: Component = () => {
const [dialogOpen, setDialogOpen] = createSignal(false)
const [selectDialogOpen, setSelectDialogOpen] = createSignal(false)
const [inputValue, setInputValue] = createSignal("")
const Content = (props: { dark?: boolean }) => (
<div class={`${props.dark ? "dark" : ""}`}>
<h3>Buttons</h3>
@@ -35,9 +54,6 @@ const Demo: Component = () => {
<h3>Select</h3>
<section>
<Select
// we have to pass dark bc of the portal,
// normally wouldn't be needed bc root element
// would have theme class
class={props.dark ? "dark" : ""}
variant="primary"
options={["Option 1", "Option 2", "Option 3"]}
@@ -117,6 +133,123 @@ const Demo: Component = () => {
{(x) => <div>{x}</div>}
</List>
</section>
<h3>Input</h3>
<section>
<Input
placeholder="Enter text..."
value={inputValue()}
onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setInputValue(e.currentTarget.value)}
/>
<Input placeholder="Disabled input" disabled />
<Input type="password" placeholder="Password input" />
</section>
<h3>Icons</h3>
<section>
<Icon name="close" />
<Icon name="checkmark" />
<Icon name="chevron-down" />
<Icon name="chevron-up" />
<Icon name="chevron-left" />
<Icon name="chevron-right" />
<Icon name="search" />
<Icon name="loading" />
</section>
<h3>Icon Buttons</h3>
<section>
<IconButton icon="close" onClick={() => console.log("Close clicked")} />
<IconButton icon="checkmark" onClick={() => console.log("Check clicked")} />
<IconButton icon="search" onClick={() => console.log("Search clicked")} disabled />
</section>
<h3>Dialog</h3>
<section>
<Button onClick={() => setDialogOpen(true)}>Open Dialog</Button>
<Dialog open={dialogOpen()} onOpenChange={setDialogOpen}>
<Dialog.Title>Example Dialog</Dialog.Title>
<Dialog.Description>This is an example dialog with a title and description.</Dialog.Description>
<div style={{ "margin-top": "16px", display: "flex", gap: "8px", "justify-content": "flex-end" }}>
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={() => setDialogOpen(false)}>
Confirm
</Button>
</div>
</Dialog>
</section>
<h3>Select Dialog</h3>
<section>
<Button onClick={() => setSelectDialogOpen(true)}>Open Select Dialog</Button>
<SelectDialog
title="Select an Option"
defaultOpen={selectDialogOpen()}
onOpenChange={setSelectDialogOpen}
items={["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"]}
key={(x) => x}
onSelect={(option) => {
console.log("Selected:", option)
setSelectDialogOpen(false)
}}
placeholder="Search options..."
>
{(item) => <div>{item}</div>}
</SelectDialog>
</section>
<h3>Collapsible</h3>
<section>
<Collapsible>
<Collapsible.Trigger>
<Button variant="secondary">Toggle Content</Button>
</Collapsible.Trigger>
<Collapsible.Content>
<div
style={{
padding: "16px",
"background-color": "var(--surface-base)",
"border-radius": "8px",
"margin-top": "8px",
}}
>
<p>This is collapsible content that can be toggled open and closed.</p>
<p>It animates smoothly using CSS animations.</p>
</div>
</Collapsible.Content>
</Collapsible>
</section>
<h3>Accordion</h3>
<section>
<Accordion collapsible>
<Accordion.Item value="item-1">
<Accordion.Header>
<Accordion.Trigger>What is Kobalte?</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div style={{ padding: "16px" }}>
<p>Kobalte is a UI toolkit for building accessible web apps and design systems with SolidJS.</p>
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Header>
<Accordion.Trigger>Is it accessible?</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div style={{ padding: "16px" }}>
<p>Yes. It adheres to the WAI-ARIA design patterns.</p>
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-3">
<Accordion.Header>
<Accordion.Trigger>Can it be animated?</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div style={{ padding: "16px" }}>
<p>Yes! You can animate the content height using CSS animations.</p>
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion>
</section>
</div>
)
+2
View File
@@ -5,7 +5,9 @@
@import "./base.css" layer(base);
@import "../components/accordion.css" layer(components);
@import "../components/button.css" layer(components);
@import "../components/collapsible.css" layer(components);
@import "../components/dialog.css" layer(components);
@import "../components/icon.css" layer(components);
@import "../components/icon-button.css" layer(components);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "0.15.15",
"version": "0.15.18",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1
View File
@@ -28,6 +28,7 @@ OpenCode comes with several built-in LSP servers for popular languages:
| svelte | .svelte | Auto-installs for Svelte projects |
| astro | .astro | Auto-installs for Astro projects |
| jdtls | .java | `Java SDK (version 21+)` installed |
| lua-ls | .lua | Auto-installs for Lua projects |
LSP servers are automatically enabled when one of the above file extensions are detected and the requirements are met.
+30 -21
View File
@@ -209,27 +209,27 @@ const { providers, default: defaults } = await client.config.providers()
### Sessions
| Method | Description | Notes |
| ---------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `session.list()` | List sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
| `session.get({ path })` | Get session | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.children({ path })` | List child sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
| `session.create({ body })` | Create session | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.delete({ path })` | Delete session | Returns `boolean` |
| `session.update({ path, body })` | Update session properties | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.init({ path, body })` | Analyze app and create `AGENTS.md` | Returns `boolean` |
| `session.abort({ path })` | Abort a running session | Returns `boolean` |
| `session.share({ path })` | Share session | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.unshare({ path })` | Unshare session | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.summarize({ path, body })` | Summarize session | Returns `boolean` |
| `session.messages({ path })` | List messages in a session | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}[]` |
| `session.message({ path })` | Get message details | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
| `session.prompt({ path, body })` | Send prompt message | Returns `{ info: `<a href={typesUrl}><code>AssistantMessage</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
| `session.command({ path, body })` | Send command to session | Returns `{ info: `<a href={typesUrl}><code>AssistantMessage</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
| `session.shell({ path, body })` | Run a shell command | Returns <a href={typesUrl}><code>AssistantMessage</code></a> |
| `session.revert({ path, body })` | Revert a message | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.unrevert({ path })` | Restore reverted messages | Returns <a href={typesUrl}><code>Session</code></a> |
| `postSessionByIdPermissionsByPermissionId({ path, body })` | Respond to a permission request | Returns `boolean` |
| Method | Description | Notes |
| ---------------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `session.list()` | List sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
| `session.get({ path })` | Get session | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.children({ path })` | List child sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
| `session.create({ body })` | Create session | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.delete({ path })` | Delete session | Returns `boolean` |
| `session.update({ path, body })` | Update session properties | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.init({ path, body })` | Analyze app and create `AGENTS.md` | Returns `boolean` |
| `session.abort({ path })` | Abort a running session | Returns `boolean` |
| `session.share({ path })` | Share session | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.unshare({ path })` | Unshare session | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.summarize({ path, body })` | Summarize session | Returns `boolean` |
| `session.messages({ path })` | List messages in a session | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}[]` |
| `session.message({ path })` | Get message details | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
| `session.prompt({ path, body })` | Send prompt message | `body.noReply: true` returns UserMessage (context only). Default returns <a href={typesUrl}><code>AssistantMessage</code></a> with AI response |
| `session.command({ path, body })` | Send command to session | Returns `{ info: `<a href={typesUrl}><code>AssistantMessage</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
| `session.shell({ path, body })` | Run a shell command | Returns <a href={typesUrl}><code>AssistantMessage</code></a> |
| `session.revert({ path, body })` | Revert a message | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.unrevert({ path })` | Restore reverted messages | Returns <a href={typesUrl}><code>Session</code></a> |
| `postSessionByIdPermissionsByPermissionId({ path, body })` | Respond to a permission request | Returns `boolean` |
---
@@ -251,6 +251,15 @@ const result = await client.session.prompt({
parts: [{ type: "text", text: "Hello!" }],
},
})
// Inject context without triggering AI response (useful for plugins)
await client.session.prompt({
path: { id: session.id },
body: {
noReply: true,
parts: [{ type: "text", text: "You are a helpful assistant." }],
},
})
```
---
+20 -20
View File
@@ -88,26 +88,26 @@ The opencode server exposes the following APIs.
### Sessions
| Method | Path | Description | Notes |
| -------- | ---------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/session` | List sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
| `GET` | `/session/:id` | Get session | Returns <a href={typesUrl}><code>Session</code></a> |
| `GET` | `/session/:id/children` | List child sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
| `POST` | `/session` | Create session | body: `{ parentID?, title? }`, returns <a href={typesUrl}><code>Session</code></a> |
| `DELETE` | `/session/:id` | Delete session | |
| `PATCH` | `/session/:id` | Update session properties | body: `{ title? }`, returns <a href={typesUrl}><code>Session</code></a> |
| `POST` | `/session/:id/init` | Analyze app and create `AGENTS.md` | body: `{ messageID, providerID, modelID }` |
| `POST` | `/session/:id/abort` | Abort a running session | |
| `POST` | `/session/:id/share` | Share session | Returns <a href={typesUrl}><code>Session</code></a> |
| `DELETE` | `/session/:id/share` | Unshare session | Returns <a href={typesUrl}><code>Session</code></a> |
| `POST` | `/session/:id/summarize` | Summarize session | |
| `GET` | `/session/:id/message` | List messages in a session | Returns `{ info: `<a href={typesUrl}>Message</a>`, parts: `<a href={typesUrl}>Part[]</a>`}[]` |
| `GET` | `/session/:id/message/:messageID` | Get message details | Returns `{ info: `<a href={typesUrl}>Message</a>`, parts: `<a href={typesUrl}>Part[]</a>`}` |
| `POST` | `/session/:id/message` | Send chat message | body matches [`ChatInput`](https://github.com/sst/opencode/blob/main/packages/opencode/src/session/index.ts#L358), returns <a href={typesUrl}><code>Message</code></a> |
| `POST` | `/session/:id/shell` | Run a shell command | body matches [`CommandInput`](https://github.com/sst/opencode/blob/main/packages/opencode/src/session/index.ts#L1007), returns <a href={typesUrl}><code>Message</code></a> |
| `POST` | `/session/:id/revert` | Revert a message | body: `{ messageID }` |
| `POST` | `/session/:id/unrevert` | Restore reverted messages | |
| `POST` | `/session/:id/permissions/:permissionID` | Respond to a permission request | body: `{ response }` |
| Method | Path | Description | Notes |
| -------- | ---------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/session` | List sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
| `GET` | `/session/:id` | Get session | Returns <a href={typesUrl}><code>Session</code></a> |
| `GET` | `/session/:id/children` | List child sessions | Returns <a href={typesUrl}><code>Session[]</code></a> |
| `POST` | `/session` | Create session | body: `{ parentID?, title? }`, returns <a href={typesUrl}><code>Session</code></a> |
| `DELETE` | `/session/:id` | Delete session | |
| `PATCH` | `/session/:id` | Update session properties | body: `{ title? }`, returns <a href={typesUrl}><code>Session</code></a> |
| `POST` | `/session/:id/init` | Analyze app and create `AGENTS.md` | body: `{ messageID, providerID, modelID }` |
| `POST` | `/session/:id/abort` | Abort a running session | |
| `POST` | `/session/:id/share` | Share session | Returns <a href={typesUrl}><code>Session</code></a> |
| `DELETE` | `/session/:id/share` | Unshare session | Returns <a href={typesUrl}><code>Session</code></a> |
| `POST` | `/session/:id/summarize` | Summarize session | |
| `GET` | `/session/:id/message` | List messages in a session | Returns `{ info: `<a href={typesUrl}>Message</a>`, parts: `<a href={typesUrl}>Part[]</a>`}[]` |
| `GET` | `/session/:id/message/:messageID` | Get message details | Returns `{ info: `<a href={typesUrl}>Message</a>`, parts: `<a href={typesUrl}>Part[]</a>`}` |
| `POST` | `/session/:id/message` | Send chat message | body matches [`ChatInput`](https://github.com/sst/opencode/blob/main/packages/opencode/src/session/index.ts#L358). Optional `noReply: true` skips AI inference and returns UserMessage. Returns <a href={typesUrl}><code>Message</code></a> |
| `POST` | `/session/:id/shell` | Run a shell command | body matches [`CommandInput`](https://github.com/sst/opencode/blob/main/packages/opencode/src/session/index.ts#L1007), returns <a href={typesUrl}><code>Message</code></a> |
| `POST` | `/session/:id/revert` | Revert a message | body: `{ messageID }` |
| `POST` | `/session/:id/unrevert` | Restore reverted messages | |
| `POST` | `/session/:id/permissions/:permissionID` | Respond to a permission request | body: `{ response }` |
---
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "0.15.15",
"version": "0.15.18",
"publisher": "sst-dev",
"repository": {
"type": "git",