Compare commits

...

44 Commits

Author SHA1 Message Date
Dax f884766445 v2 message format and upgrade to ai sdk v5 (#743)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Liang-Shih Lin <liangshihlin@proton.me>
Co-authored-by: Dominik Engelhardt <dominikengelhardt@ymail.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-07-07 15:53:43 -04:00
Jay V 76b2e4539c docs: discord 2025-07-07 14:44:37 -04:00
Dominik Engelhardt d87922c0eb Fix Elixir LSP startup (#726) 2025-07-06 23:37:46 -04:00
Liang-Shih Lin 2446483df5 fix: Skip opencode upgrade if same version (#720) 2025-07-06 23:36:59 -04:00
GitHub Action f4c453155d Update download stats 2025-07-06 2025-07-06 12:03:56 +00:00
Dax Raad 969ad80ed2 fix openrouter caching with anthropic, should be a lot cheaper 2025-07-05 11:39:54 -04:00
GitHub Action af064b41d7 Update download stats 2025-07-05 2025-07-05 12:03:56 +00:00
Dax Raad ea6bfef21a use full filepath 2025-07-04 17:58:03 -04:00
Jay V 107363b1d9 docs: fix show more in share page 2025-07-04 17:57:12 -04:00
Dax Raad 85214d7c59 fix input bar not rendering capital letters 2025-07-04 17:21:51 -04:00
Timo Clasen 997cb2d945 fix(tui): optimistic rendering (#692) 2025-07-04 16:06:57 -05:00
Dax Raad 45b139390c make file attachments work good like 2025-07-04 16:21:26 -04:00
Jay V 994368de15 docs: share fix scrolling again 2025-07-04 13:53:25 -04:00
Jay V 143fd8e076 docs: share improve markdown rendering of ai responses 2025-07-04 13:53:25 -04:00
Dax Raad 06dba28bd6 wip: fix media type 2025-07-04 12:50:52 -04:00
adamdottv b8d276a049 fix(tui): full paths for attachments 2025-07-04 11:42:22 -05:00
Dax Raad ee01f01271 file attachments 2025-07-04 12:24:01 -04:00
adamdottv 32d5db4f0a fix(tui): markdown wrapping off sometimes 2025-07-04 11:16:38 -05:00
adamdottv f6108b7be8 fix(tui): handle pdf and image @ files 2025-07-04 11:13:09 -05:00
adamdottv 94ef341c9d feat(tui): render attachments 2025-07-04 10:55:02 -05:00
adamdottv f9abc7c84f feat(tui): file attachments 2025-07-04 10:55:02 -05:00
adamdottv 891ed6ebc0 fix(tui): slower startup due to file.status 2025-07-04 10:55:01 -05:00
Dax Raad 163e23a68b removed banned command concept 2025-07-04 11:32:12 -04:00
Vladimir f13b0af491 docs: Fix invalid json in the mcp example config (#645) 2025-07-04 11:24:13 -04:00
Aiden Cline 4a0be45d3d chore: document instructions configuration option (#670) 2025-07-04 11:22:45 -04:00
Dax Raad 23788674c8 disable snapshots temporarily 2025-07-04 08:45:18 -04:00
GitHub Action 121eb24e73 Update download stats 2025-07-04 2025-07-04 12:26:16 +00:00
Dax Raad 571d60182a improve snapshotting speed further 2025-07-03 21:36:09 -04:00
Jay V 167a9dcaf3 docs: share fix scroll to anchor 2025-07-03 20:30:21 -04:00
Dax Raad 37327259cb ci: ignore 2025-07-03 20:30:02 -04:00
Dax Raad cdb25656d5 improve snapshot speed 2025-07-03 20:16:25 -04:00
Jay V 25c876caa2 docs: share fix last message not expandable 2025-07-03 19:33:55 -04:00
Dax Raad cf83e31f23 add elixir lsp support 2025-07-03 19:29:51 -04:00
Dax Raad 3bc238b58b wip: logs 2025-07-03 19:29:51 -04:00
Jay V b8de69dced docs: fix share page scroll performance 2025-07-03 19:15:38 -04:00
Jay V e7fcb692a4 docs: tweak page title 2025-07-03 16:23:08 -04:00
Timo Clasen dae38574ab chore: add dev script (#666) 2025-07-03 14:43:25 -05:00
Dax Raad ed4f862b49 fix /unshare 2025-07-03 15:34:04 -04:00
adamdottv fce59db94a chore: simplify completions 2025-07-03 12:48:22 -05:00
Jay V 3e2a0c7281 docs: share handle slow loading pages 2025-07-03 13:15:21 -04:00
adamdottv 5a0910ea79 chore: better local dev with stainless script 2025-07-03 11:49:15 -05:00
adamdottv 1dffabcfda fix(tui): panic on completions failure 2025-07-03 10:53:43 -05:00
adamdottv c389e0ed43 fix(tui): redundant tool calls in each message in collapsed mode 2025-07-03 10:42:27 -05:00
Dax Raad 204801052a flag for disabling file watcher 2025-07-03 10:37:08 -04:00
193 changed files with 16270 additions and 6616 deletions
+1 -1
View File
@@ -28,5 +28,5 @@ jobs:
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add STATS.md
git diff --staged --quiet || git commit -m "Update download stats $(date -I)"
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
git push
+1
View File
@@ -5,3 +5,4 @@ node_modules
.env
.idea
.vscode
openapi.json
+2 -2
View File
@@ -9,7 +9,7 @@
</p>
<p align="center">AI coding agent, built for the terminal.</p>
<p align="center">
<a href="https://opencode.ai/docs"><img alt="View docs" src="https://img.shields.io/badge/view-docs-blue?style=flat-square" /></a>
<a href="https://discord.gg/opencode"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -76,4 +76,4 @@ The other confusingly named repo has no relation to this one. You can [read the
---
**Join our community** [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
**Join our community** [Discord](https://discord.gg/opencode) | [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
+3
View File
@@ -7,3 +7,6 @@
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
+45 -42
View File
@@ -5,7 +5,7 @@
"name": "opencode",
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6",
"sst": "3.17.8",
},
},
"packages/function": {
@@ -78,6 +78,7 @@
"lang-map": "0.4.0",
"luxon": "3.6.1",
"marked": "15.0.12",
"marked-shiki": "1.2.0",
"rehype-autolink-headings": "7.1.0",
"sharp": "0.32.5",
"shiki": "3.4.2",
@@ -95,30 +96,22 @@
"sharp",
"esbuild",
],
"patchedDependencies": {
"ai@4.3.16": "patches/ai@4.3.16.patch",
},
"overrides": {
"zod": "3.24.2",
},
"catalog": {
"@types/node": "22.13.9",
"ai": "4.3.16",
"ai": "5.0.0-beta.7",
"typescript": "5.8.2",
"zod": "3.24.2",
"zod": "3.25.49",
},
"packages": {
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@2.2.10", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icLGO7Q0NinnHIPgT+y1QjHVwH4HwV+brWbvM+FfCG2Afpa89PyKa3Ret91kGjZpBgM/xnj1B7K5eM+rRlsXQA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-beta.3", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-g49gMSkXy94lYvl5LRh438OR/0JCG6ol0jV+iLot7cy5HLltZlGocEuauETBu4b10mDXOd7XIjTEZoQpYFMYLQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-beta.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Z8SPncMtS3RsoXITmT7NVwrAq6M44dmw0DoUOYJqNNtCu8iMWuxB8Nxsoqpa0uEEy9R1V1ZThJAXTYgjTUxl3w=="],
"@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0-beta.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-H4K+4weOVgWqrDDeAbQWoA4U5mN4WrQPHQFdH7ynQYcnhj/pzctU9Q6mGlR5ESMWxaXxazxlOblSITlXo9bahA=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
@@ -462,12 +455,10 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@@ -492,6 +483,8 @@
"@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="],
"@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="],
@@ -512,7 +505,7 @@
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
"ai": ["ai@5.0.0-beta.7", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-beta.3", "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-oC4KzUJCQPMB7v9rCqL/rVk2ogZvI6lYiXfKjzPYHwa1zIgy329qqRLmAd3mKEDTTG6By1r0zasQu7FKmG+4gw=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -602,7 +595,7 @@
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -730,8 +723,6 @@
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
"diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
@@ -800,7 +791,7 @@
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
"eventsource-parser": ["eventsource-parser@3.0.3", "", {}, "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
@@ -1022,8 +1013,6 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
@@ -1050,6 +1039,8 @@
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"marked-shiki": ["marked-shiki@1.2.0", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-N924hp8veE6Mc91g5/kCNVoTU7TkeJfB2G2XEWb+k1fVA0Bck2T0rVt93d39BlOYH6ohP4Q9BFlPk+UkblhXbg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="],
@@ -1338,8 +1329,6 @@
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
@@ -1476,23 +1465,23 @@
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
"sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="],
"sst": ["sst@3.17.8", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.8", "sst-darwin-x64": "3.17.8", "sst-linux-arm64": "3.17.8", "sst-linux-x64": "3.17.8", "sst-linux-x86": "3.17.8", "sst-win32-arm64": "3.17.8", "sst-win32-x64": "3.17.8", "sst-win32-x86": "3.17.8" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-P/a9/ZsjtQRrTBerBMO1ODaVa5HVTmNLrQNJiYvu2Bgd0ov+vefQeHv6oima8HLlPwpDIPS2gxJk8BZrTZMfCA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-50P6YRMnZVItZUfB0+NzqMww2mmm4vB3zhTVtWUtGoXeiw78g1AEnVlmS28gYXPHM1P987jTvR7EON9u9ig/Dg=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-P0pnMHCmpkpcsxkWpilmeoD79LkbkoIcv6H0aeM9ArT/71/JBhvqH+HjMHSJCzni/9uR6er+nH5F+qol0UO6Bw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-vun54YA/UzprCu9p8BC4rMwFU5Cj9xrHAHYLYUp/yq4H0pfmBIiQM62nsfIKizRThe/TkBFy60EEi9myf6raYA=="],
"sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
"sst-linux-x64": ["sst-linux-x64@3.17.8", "", { "os": "linux", "cpu": "x64" }, "sha512-HqByCaLE2gEJbM20P1QRd+GqDMAiieuU53FaZA1F+AGxQi+kR82NWjrPqFcMj4dMYg8w/TWXuV+G5+PwoUmpDw=="],
"sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
"sst-linux-x86": ["sst-linux-x86@3.17.8", "", { "os": "linux", "cpu": "none" }, "sha512-bCd6QM3MejfSmdvg8I/k+aUJQIZEQJg023qmN78fv00vwlAtfECvY7tjT9E2m3LDp33pXrcRYbFOQzPu+tWFfA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-pilx0n8gm4aHJae/vNiqIwZkWF3tdwWzD/ON7hkytw+CVSZ0FXtyFW/yO/+2u3Yw0Kj0lSWPnUqYgm/eHPLwQA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
"sst-win32-x64": ["sst-win32-x64@3.17.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb0FVRyiOtESudF1V8ucW65PuHrx/iOHUamIO0JnbujWNHZBTRPB2QHN1dbewgkueYDaCmyS8lvuIImLwYJnzQ=="],
"sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
"sst-win32-x86": ["sst-win32-x86@3.17.8", "", { "os": "win32", "cpu": "none" }, "sha512-oVmFa/PoElQmfnGJlB0w6rPXiYuldiagO6AbrLMT/6oAnWerLQ8Uhv9tJWfMh3xtPLImQLTjxDo1v0AIzEv9QA=="],
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
@@ -1524,8 +1513,6 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
"tar-fs": ["tar-fs@3.0.9", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
@@ -1534,8 +1521,6 @@
"thread-stream": ["thread-stream@0.15.2", "", { "dependencies": { "real-require": "^0.1.0" } }, "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -1620,8 +1605,6 @@
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -1698,7 +1681,7 @@
"youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="],
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"zod": ["zod@3.25.49", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
"zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="],
@@ -1710,12 +1693,22 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q=="],
"@astrojs/sitemap/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"@aws-crypto/crc32/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@@ -1734,6 +1727,8 @@
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@modelcontextprotocol/sdk/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
"@openauthjs/openauth/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
@@ -1770,10 +1765,14 @@
"astro/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"astro/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"eventsource/eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"get-source/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1786,8 +1785,12 @@
"miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
"opencontrol/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"opencontrol/zod-to-json-schema": ["zod-to-json-schema@3.24.3", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
+7 -7
View File
@@ -5,7 +5,9 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"dev": "bun run packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"stainless": "bun run ./packages/opencode/src/index.ts serve ",
"postinstall": "./scripts/hooks"
},
"workspaces": {
@@ -15,13 +17,13 @@
"catalog": {
"typescript": "5.8.2",
"@types/node": "22.13.9",
"zod": "3.24.2",
"ai": "4.3.16"
"zod": "3.25.49",
"ai": "5.0.0-beta.7"
}
},
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6"
"sst": "3.17.8"
},
"repository": {
"type": "git",
@@ -29,10 +31,8 @@
},
"license": "MIT",
"prettier": {
"semi": false
},
"overrides": {
"zod": "3.24.2"
"semi": false,
"printWidth": 120
},
"trustedDependencies": [
"esbuild",
+3 -8
View File
@@ -38,10 +38,7 @@ export class SyncServer extends DurableObject<Env> {
async publish(key: string, content: any) {
const sessionID = await this.getSessionID()
if (
!key.startsWith(`session/info/${sessionID}`) &&
!key.startsWith(`session/message/${sessionID}/`)
)
if (!key.startsWith(`session/info/${sessionID}`) && !key.startsWith(`session/message/${sessionID}/`))
return new Response("Error: Invalid key", { status: 400 })
// store message
@@ -184,8 +181,7 @@ export default {
}
const id = url.searchParams.get("id")
console.log("share_poll", id)
if (!id)
return new Response("Error: Share ID is required", { status: 400 })
if (!id) return new Response("Error: Share ID is required", { status: 400 })
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
return stub.fetch(request)
}
@@ -193,8 +189,7 @@ export default {
if (request.method === "GET" && method === "share_data") {
const id = url.searchParams.get("id")
console.log("share_data", id)
if (!id)
return new Response("Error: Share ID is required", { status: 400 })
if (!id) return new Response("Error: Share ID is required", { status: 400 })
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
const data = await stub.getData()
-1
View File
@@ -1,4 +1,3 @@
node_modules
research
dist
gen
+10 -31
View File
@@ -57,8 +57,7 @@ for (const [os, arch] of targets) {
2,
),
)
if (!dry)
await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
if (!dry) await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
optionalDependencies[name] = version
}
@@ -82,8 +81,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
2,
),
)
if (!dry)
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
if (!dry) await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
if (!snapshot) {
// Github Release
@@ -91,15 +89,11 @@ if (!snapshot) {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
}
const previous = await fetch(
"https://api.github.com/repos/sst/opencode/releases/latest",
)
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json())
.then((data) => data.tag_name)
const commits = await fetch(
`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`,
)
const commits = await fetch(`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`)
.then((res) => res.json())
.then((data) => data.commits || [])
@@ -117,26 +111,13 @@ if (!snapshot) {
})
.join("\n")
if (!dry)
await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
if (!dry) await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
// Calculate SHA values
const arm64Sha =
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const x64Sha =
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macX64Sha =
await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macArm64Sha =
await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
// AUR package
const pkgbuild = [
@@ -170,9 +151,7 @@ if (!snapshot) {
for (const pkg of ["opencode", "opencode-bin"]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(
pkgbuild.replace("${pkg}", pkg),
)
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild.replace("${pkg}", pkg))
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
+3 -12
View File
@@ -45,23 +45,14 @@ export namespace App {
}
export const provideExisting = ctx.provide
export async function provide<T>(
input: Input,
cb: (app: App.Info) => Promise<T>,
) {
export async function provide<T>(input: Input, cb: (app: App.Info) => Promise<T>) {
log.info("creating", {
cwd: input.cwd,
})
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) =>
x ? path.dirname(x) : undefined,
)
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) => (x ? path.dirname(x) : undefined))
log.info("git", { git })
const data = path.join(
Global.Path.data,
"project",
git ? directory(git) : "global",
)
const data = path.join(Global.Path.data, "project", git ? directory(git) : "global")
const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as {
initialized: number
+12 -21
View File
@@ -10,14 +10,8 @@ export namespace AuthAnthropic {
url.searchParams.set("code", "true")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("response_type", "code")
url.searchParams.set(
"redirect_uri",
"https://console.anthropic.com/oauth/code/callback",
)
url.searchParams.set(
"scope",
"org:create_api_key user:profile user:inference",
)
url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
url.searchParams.set("scope", "org:create_api_key user:profile user:inference")
url.searchParams.set("code_challenge", pkce.challenge)
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("state", pkce.verifier)
@@ -57,20 +51,17 @@ export namespace AuthAnthropic {
const info = await Auth.get("anthropic")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
const response = await fetch(
"https://console.anthropic.com/v1/oauth/token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: info.refresh,
client_id: CLIENT_ID,
}),
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
)
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: info.refresh,
client_id: CLIENT_ID,
}),
})
if (!response.ok) return
const json = await response.json()
await Auth.set("anthropic", {
+1 -3
View File
@@ -4,9 +4,7 @@ import path from "path"
export const AuthCopilot = lazy(async () => {
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
const response = fetch(
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
)
const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts")
.then((x) => Bun.write(file, x))
.catch(() => {})
+1 -4
View File
@@ -122,10 +122,7 @@ export namespace AuthGithubCopilot {
return tokenData.token
}
export const DeviceCodeError = NamedError.create(
"DeviceCodeError",
z.object({}),
)
export const DeviceCodeError = NamedError.create("DeviceCodeError", z.object({}))
export const TokenExchangeError = NamedError.create(
"TokenExchangeError",
+13 -8
View File
@@ -8,10 +8,7 @@ import { readableStreamToText } from "bun"
export namespace BunProc {
const log = Log.create({ service: "bun" })
export async function run(
cmd: string[],
options?: Bun.SpawnOptions.OptionsObject<any, any, any>,
) {
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
log.info("running", {
cmd: [which(), ...cmd],
...options,
@@ -26,9 +23,17 @@ export namespace BunProc {
BUN_BE_BUN: "1",
},
})
const code = await result.exited;
const stdout = result.stdout ? typeof result.stdout === "number" ? result.stdout : await readableStreamToText(result.stdout) : undefined
const stderr = result.stderr ? typeof result.stderr === "number" ? result.stderr : await readableStreamToText(result.stderr) : undefined
const code = await result.exited
const stdout = result.stdout
? typeof result.stdout === "number"
? result.stdout
: await readableStreamToText(result.stdout)
: undefined
const stderr = result.stderr
? typeof result.stderr === "number"
? result.stderr
: await readableStreamToText(result.stderr)
: undefined
log.info("done", {
code,
stdout,
@@ -61,7 +66,7 @@ export namespace BunProc {
if (parsed.dependencies[pkg] === version) return mod
parsed.dependencies[pkg] = version
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
await BunProc.run(["install", "--cwd", Global.Path.cache, "--registry=https://registry.npmjs.org"], {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
+2 -8
View File
@@ -18,10 +18,7 @@ export namespace Bus {
const registry = new Map<string, EventDefinition>()
export function event<Type extends string, Properties extends ZodType>(
type: Type,
properties: Properties,
) {
export function event<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
@@ -72,10 +69,7 @@ export namespace Bus {
export function subscribe<Definition extends EventDefinition>(
def: Definition,
callback: (event: {
type: Definition["type"]
properties: z.infer<Definition["properties"]>
}) => void,
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
) {
return raw(def.type, callback)
}
+1 -4
View File
@@ -5,10 +5,7 @@ import { Format } from "../format"
import { LSP } from "../lsp"
import { Share } from "../share/share"
export async function bootstrap<T>(
input: App.Input,
cb: (app: App.Info) => Promise<T>,
) {
export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Promise<T>) {
return App.provide(input, async (app) => {
Share.init()
Format.init()
+6 -22
View File
@@ -15,11 +15,7 @@ export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
builder: (yargs) =>
yargs
.command(AuthLoginCommand)
.command(AuthLogoutCommand)
.command(AuthListCommand)
.demandCommand(),
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
async handler() {},
})
@@ -31,9 +27,7 @@ export const AuthListCommand = cmd({
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir)
? authPath.replace(homedir, "~")
: authPath
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await Auth.all().then((x) => Object.entries(x))
const database = await ModelsDev.get()
@@ -114,8 +108,7 @@ export const AuthLoginCommand = cmd({
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) =>
x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only",
validate: (x) => (x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
@@ -186,17 +179,13 @@ export const AuthLoginCommand = cmd({
await new Promise((resolve) => setTimeout(resolve, 10))
const deviceInfo = await copilot.authorize()
prompts.note(
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
)
prompts.note(`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`)
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
while (true) {
await new Promise((resolve) =>
setTimeout(resolve, deviceInfo.interval * 1000),
)
await new Promise((resolve) => setTimeout(resolve, deviceInfo.interval * 1000))
const response = await copilot.poll(deviceInfo.device)
if (response.status === "pending") continue
if (response.status === "success") {
@@ -248,12 +237,7 @@ export const AuthLogoutCommand = cmd({
const providerID = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label:
(database[key]?.name || key) +
UI.Style.TEXT_DIM +
" (" +
value.type +
")",
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
+1 -2
View File
@@ -31,7 +31,6 @@ const FileStatusCommand = cmd({
export const FileCommand = cmd({
command: "file",
builder: (yargs) =>
yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
builder: (yargs) => yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
async handler() {},
})
+1 -3
View File
@@ -17,9 +17,7 @@ export const DebugCommand = cmd({
command: "wait",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1_000 * 60 * 60 * 24),
)
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
})
},
})
+3 -6
View File
@@ -5,15 +5,13 @@ import { Log } from "../../../util/log"
export const LSPCommand = cmd({
command: "lsp",
builder: (yargs) =>
yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
builder: (yargs) => yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
async handler() {},
})
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
@@ -24,8 +22,7 @@ const DiagnosticsCommand = cmd({
export const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) =>
yargs.positional("query", { type: "string", demandOption: true }),
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
@@ -5,12 +5,7 @@ import { cmd } from "../cmd"
export const RipgrepCommand = cmd({
command: "rg",
builder: (yargs) =>
yargs
.command(TreeCommand)
.command(FilesCommand)
.command(SearchCommand)
.demandCommand(),
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
async handler() {},
})
@@ -4,11 +4,7 @@ import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
builder: (yargs) =>
yargs
.command(SnapshotCreateCommand)
.command(SnapshotRestoreCommand)
.demandCommand(),
builder: (yargs) => yargs.command(SnapshotCreateCommand).command(SnapshotRestoreCommand).demandCommand(),
async handler() {},
})
+1 -4
View File
@@ -10,9 +10,6 @@ export const GenerateCommand = {
const dir = "gen"
await fs.rmdir(dir, { recursive: true }).catch(() => {})
await fs.mkdir(dir, { recursive: true })
await Bun.write(
path.join(dir, "openapi.json"),
JSON.stringify(specs, null, 2),
)
await Bun.write(path.join(dir, "openapi.json"), JSON.stringify(specs, null, 2))
},
} satisfies CommandModule
+8 -28
View File
@@ -2,12 +2,12 @@ import type { Argv } from "yargs"
import { Bus } from "../../bus"
import { Provider } from "../../provider/provider"
import { Session } from "../../session"
import { Message } from "../../session/message"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
import { MessageV2 } from "../../session/message-v2"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -84,21 +84,12 @@ export const RunCommand = cmd({
const cfg = await Config.get()
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
await Session.share(session.id)
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://opencode.ai/s/" +
session.id.slice(-8),
)
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + session.id.slice(-8))
}
UI.empty()
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
)
const { providerID, modelID } = args.model ? Provider.parseModel(args.model) : await Provider.defaultModel()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "@ ", UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`)
UI.empty()
function printEvent(color: string, type: string, title: string) {
@@ -110,24 +101,13 @@ export const RunCommand = cmd({
)
}
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
if (evt.properties.sessionID !== session.id) return
const part = evt.properties.part
const message = await Session.getMessage(
evt.properties.sessionID,
evt.properties.messageID,
)
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
const metadata = message.metadata.tool[part.toolInvocation.toolCallId]
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD,
]
printEvent(color, tool, metadata?.title || "Unknown")
if (part.type === "tool" && part.state.status === "completed") {
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
printEvent(color, tool, part.state.title || "Unknown")
}
if (part.type === "text") {
+1 -3
View File
@@ -38,9 +38,7 @@ export const ServeCommand = cmd({
hostname,
})
console.log(
`opencode server listening on http://${server.hostname}:${server.port}`,
)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})
+5 -3
View File
@@ -9,6 +9,7 @@ import fs from "fs/promises"
import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
import { Log } from "../../util/log"
export const TuiCommand = cmd({
command: "$0 [project]",
@@ -39,9 +40,7 @@ export const TuiCommand = cmd({
})
let cmd = ["go", "run", "./main.go"]
let cwd = Bun.fileURLToPath(
new URL("../../../../tui/cmd/opencode", import.meta.url),
)
let cwd = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
let binaryName = blob.name
@@ -57,6 +56,9 @@ export const TuiCommand = cmd({
cwd = process.cwd()
cmd = [binary]
}
Log.Default.info("tui", {
cmd,
})
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
+9 -5
View File
@@ -27,22 +27,26 @@ export const UpgradeCommand = {
const detectedMethod = await Installation.method()
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
prompts.log.error(
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
)
prompts.log.error(`opencode is installed to ${process.execPath} and seems to be managed by a package manager`)
prompts.outro("Done")
return
}
prompts.log.info("Using method: " + method)
const target = args.target ?? (await Installation.latest())
if (Installation.VERSION === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
prompts.outro("Done")
return
}
prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed")
if (err instanceof Installation.UpgradeFailedError)
prompts.log.error(err.data.stderr)
if (err instanceof Installation.UpgradeFailedError) prompts.log.error(err.data.stderr)
else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")
return
+2 -5
View File
@@ -5,14 +5,11 @@ import { UI } from "./ui"
export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (Config.JsonError.isInstance(input))
return `Config file at ${input.data.path} is not valid JSON`
if (Config.JsonError.isInstance(input)) return `Config file at ${input.data.path} is not valid JSON`
if (Config.InvalidError.isInstance(input))
return [
`Config file at ${input.data.path} is invalid`,
...(input.data.issues?.map(
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []),
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
].join("\n")
if (UI.CancelledError.isInstance(input)) return ""
+24 -98
View File
@@ -29,18 +29,12 @@ export namespace Config {
export const McpLocal = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z
.string()
.array()
.describe("Command and arguments to run the MCP server"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
@@ -51,10 +45,7 @@ export namespace Config {
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
@@ -66,67 +57,31 @@ export namespace Config {
export const Keybinds = z
.object({
leader: z
.string()
.optional()
.describe("Leader key for keybind combinations"),
leader: z.string().optional().describe("Leader key for keybind combinations"),
help: z.string().optional().describe("Show help dialog"),
editor_open: z.string().optional().describe("Open external editor"),
session_new: z.string().optional().describe("Create a new session"),
session_list: z.string().optional().describe("List all sessions"),
session_share: z.string().optional().describe("Share current session"),
session_interrupt: z
.string()
.optional()
.describe("Interrupt current session"),
session_compact: z
.string()
.optional()
.describe("Toggle compact mode for session"),
session_interrupt: z.string().optional().describe("Interrupt current session"),
session_compact: z.string().optional().describe("Toggle compact mode for session"),
tool_details: z.string().optional().describe("Show tool details"),
model_list: z.string().optional().describe("List available models"),
theme_list: z.string().optional().describe("List available themes"),
project_init: z
.string()
.optional()
.describe("Initialize project configuration"),
project_init: z.string().optional().describe("Initialize project configuration"),
input_clear: z.string().optional().describe("Clear input field"),
input_paste: z.string().optional().describe("Paste from clipboard"),
input_submit: z.string().optional().describe("Submit input"),
input_newline: z.string().optional().describe("Insert newline in input"),
history_previous: z
.string()
.optional()
.describe("Navigate to previous history item"),
history_next: z
.string()
.optional()
.describe("Navigate to next history item"),
messages_page_up: z
.string()
.optional()
.describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.describe("Scroll messages down by one page"),
messages_half_page_up: z
.string()
.optional()
.describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
.optional()
.describe("Scroll messages down by half page"),
messages_previous: z
.string()
.optional()
.describe("Navigate to previous message"),
history_previous: z.string().optional().describe("Navigate to previous history item"),
history_next: z.string().optional().describe("Navigate to next history item"),
messages_page_up: z.string().optional().describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().describe("Scroll messages down by one page"),
messages_half_page_up: z.string().optional().describe("Scroll messages up by half page"),
messages_half_page_down: z.string().optional().describe("Scroll messages down by half page"),
messages_previous: z.string().optional().describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
messages_first: z
.string()
.optional()
.describe("Navigate to first message"),
messages_first: z.string().optional().describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
app_exit: z.string().optional().describe("Exit the application"),
})
@@ -136,33 +91,13 @@ export namespace Config {
})
export const Info = z
.object({
$schema: z
.string()
.optional()
.describe("JSON schema reference for configuration validation"),
theme: z
.string()
.optional()
.describe("Theme name to use for the interface"),
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
autoshare: z
.boolean()
.optional()
.describe("Share newly created sessions automatically"),
autoupdate: z
.boolean()
.optional()
.describe("Automatically update to the latest version"),
disabled_providers: z
.array(z.string())
.optional()
.describe("Disable providers that are loaded automatically"),
model: z
.string()
.describe(
"Model to use in the format of provider/model, eg anthropic/claude-2",
)
.optional(),
autoshare: z.boolean().optional().describe("Share newly created sessions automatically"),
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
provider: z
.record(
ModelsDev.Provider.partial().extend({
@@ -172,14 +107,8 @@ export namespace Config {
)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z
.record(z.string(), Mcp)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
instructions: z
.array(z.string())
.optional()
.describe("Additional instruction files or patterns to include"),
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
experimental: z
.object({
hook: z
@@ -227,10 +156,7 @@ export namespace Config {
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await Bun.write(
path.join(Global.Path.config, "config.json"),
JSON.stringify(result, null, 2),
)
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})
+1 -3
View File
@@ -22,9 +22,7 @@ export namespace ConfigHooks {
command: item.command,
})
Bun.spawn({
cmd: item.command.map((x) =>
x.replace("$FILE", payload.properties.file),
),
cmd: item.command.map((x) => x.replace("$FILE", payload.properties.file)),
env: item.environment,
cwd: app.path.cwd,
stdout: "ignore",
+9 -18
View File
@@ -45,10 +45,7 @@ export namespace Fzf {
log.info("found", { filepath })
return { filepath }
}
filepath = path.join(
Global.Path.bin,
"fzf" + (process.platform === "win32" ? ".exe" : ""),
)
filepath = path.join(Global.Path.bin, "fzf" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath)
if (!(await file.exists())) {
@@ -56,18 +53,15 @@ export namespace Fzf {
const arch = archMap[process.arch as keyof typeof archMap] ?? "amd64"
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
if (!config) throw new UnsupportedPlatformError({ platform: process.platform })
const version = VERSION
const platformName =
process.platform === "win32" ? "windows" : process.platform
const platformName = process.platform === "win32" ? "windows" : process.platform
const filename = `fzf-${version}-${platformName}_${arch}.${config.extension}`
const url = `https://github.com/junegunn/fzf/releases/download/v${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
@@ -86,14 +80,11 @@ export namespace Fzf {
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
const proc = Bun.spawn(["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin], {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
+5 -23
View File
@@ -24,11 +24,7 @@ export namespace File {
const app = App.info()
if (!app.git) return []
const diffOutput = await $`git diff --numstat HEAD`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
const diffOutput = await $`git diff --numstat HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
const changedFiles = []
@@ -45,19 +41,13 @@ export namespace File {
}
}
const untrackedOutput = await $`git ls-files --others --exclude-standard`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
const untrackedOutput = await $`git ls-files --others --exclude-standard`.cwd(app.path.cwd).quiet().nothrow().text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Bun.file(
path.join(app.path.root, filepath),
).text()
const content = await Bun.file(path.join(app.path.root, filepath)).text()
const lines = content.split("\n").length
changedFiles.push({
file: filepath,
@@ -72,11 +62,7 @@ export namespace File {
}
// Get deleted files
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
@@ -112,11 +98,7 @@ export namespace File {
filepath: rel,
})
if (diff !== "unmodified") {
const original = await $`git show HEAD:${rel}`
.cwd(app.path.root)
.quiet()
.nothrow()
.text()
const original = await $`git show HEAD:${rel}`.cwd(app.path.root).quiet().nothrow().text()
const patch = createPatch(file, original, content, "old", "new", {
context: Infinity,
})
+12 -36
View File
@@ -122,15 +122,11 @@ export namespace Ripgrep {
const state = lazy(async () => {
let filepath = Bun.which("rg")
if (filepath) return { filepath }
filepath = path.join(
Global.Path.bin,
"rg" + (process.platform === "win32" ? ".exe" : ""),
)
filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath)
if (!(await file.exists())) {
const platformKey =
`${process.arch}-${process.platform}` as keyof typeof PLATFORM
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
@@ -139,8 +135,7 @@ export namespace Ripgrep {
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
@@ -164,14 +159,11 @@ export namespace Ripgrep {
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
const proc = Bun.spawn(["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin], {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
@@ -193,17 +185,11 @@ export namespace Ripgrep {
return filepath
}
export async function files(input: {
cwd: string
query?: string
glob?: string
limit?: number
}) {
export async function files(input: { cwd: string; query?: string; glob?: string; limit?: number }) {
const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
if (input.query)
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
@@ -310,18 +296,8 @@ export namespace Ripgrep {
return lines.join("\n")
}
export async function search(input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
}) {
const args = [
`${await filepath()}`,
"--json",
"--hidden",
"--glob='!.git/*'",
]
export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
if (input.glob) {
for (const g of input.glob) {
+4 -4
View File
@@ -1,6 +1,8 @@
import { App } from "../app/app"
import { Log } from "../util/log"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export const state = App.state("tool.filetimes", () => {
const read: {
[sessionID: string]: {
@@ -13,6 +15,7 @@ export namespace FileTime {
})
export function read(sessionID: string, file: string) {
log.info("read", { sessionID, file })
const { read } = state()
read[sessionID] = read[sessionID] || {}
read[sessionID][file] = new Date()
@@ -24,10 +27,7 @@ export namespace FileTime {
export async function assert(sessionID: string, filepath: string) {
const time = get(sessionID, filepath)
if (!time)
throw new Error(
`You must read the file ${filepath} before overwriting it. Use the Read tool first`,
)
if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`)
const stats = await Bun.file(filepath).stat()
if (stats.mtime.getTime() > time.getTime()) {
throw new Error(
+30 -30
View File
@@ -3,6 +3,7 @@ import { Bus } from "../bus"
import fs from "fs"
import { App } from "../app/app"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
@@ -16,37 +17,36 @@ export namespace FileWatcher {
}),
),
}
const state = App.state(
"file.watcher",
() => {
const app = App.use()
if (!app.info.git) return {}
try {
const watcher = fs.watch(app.info.path.cwd, { recursive: true }, (event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
})
})
return { watcher }
} catch {
return {}
}
},
async (state) => {
state.watcher?.close()
},
)
export function init() {
App.state(
"file.watcher",
() => {
const app = App.use()
try {
const watcher = fs.watch(
app.info.path.cwd,
{ recursive: true },
(event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
})
},
)
return { watcher }
} catch {
return {}
}
},
async (state) => {
state.watcher?.close()
},
)()
if (Flag.OPENCODE_DISABLE_WATCHER) return
state()
}
}
+1
View File
@@ -1,5 +1,6 @@
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_DISABLE_WATCHER = truthy("OPENCODE_DISABLE_WATCHER")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
+1 -15
View File
@@ -94,21 +94,7 @@ export const zig: Info = {
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [
".c",
".cc",
".cpp",
".cxx",
".c++",
".h",
".hh",
".hpp",
".hxx",
".h++",
".ino",
".C",
".H",
],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
return Bun.which("clang-format") !== null
},
+4 -17
View File
@@ -26,11 +26,7 @@ export namespace Identifier {
return generateID(prefix, true, given)
}
function generateID(
prefix: keyof typeof prefixes,
descending: boolean,
given?: string,
): string {
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
if (!given) {
return generateNewID(prefix, descending)
}
@@ -42,8 +38,7 @@ export namespace Identifier {
}
function randomBase62(length: number): string {
const chars =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
@@ -52,10 +47,7 @@ export namespace Identifier {
return result
}
function generateNewID(
prefix: keyof typeof prefixes,
descending: boolean,
): string {
function generateNewID(prefix: keyof typeof prefixes, descending: boolean): string {
const currentTimestamp = Date.now()
if (currentTimestamp !== lastTimestamp) {
@@ -73,11 +65,6 @@ export namespace Identifier {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return (
prefixes[prefix] +
"_" +
timeBytes.toString("hex") +
randomBase62(LENGTH - 12)
)
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
}
+2 -8
View File
@@ -55,10 +55,7 @@ const cli = yargs(hideBin(process.argv))
.command(ServeCommand)
.command(ModelsCommand)
.fail((msg) => {
if (
msg.startsWith("Unknown argument") ||
msg.startsWith("Not enough non-option arguments")
) {
if (msg.startsWith("Unknown argument") || msg.startsWith("Not enough non-option arguments")) {
cli.showHelp("log")
}
})
@@ -97,10 +94,7 @@ try {
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (formatted === undefined)
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
if (formatted === undefined) UI.error("Unexpected error, check log file at " + Log.file() + " for more details")
process.exitCode = 1
}
+1 -2
View File
@@ -135,8 +135,7 @@ export namespace Installation {
})
}
export const VERSION =
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export async function latest() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
+17 -19
View File
@@ -1,9 +1,5 @@
import path from "path"
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import { App } from "../app/app"
import { Log } from "../util/log"
@@ -66,6 +62,7 @@ export namespace LSPClient {
log.info("sending initialize", { id: serverID })
await withTimeout(
connection.sendRequest("initialize", {
rootUri: "file://" + app.path.cwd,
processId: server.process.pid,
workspaceFolders: [
{
@@ -92,11 +89,20 @@ export namespace LSPClient {
},
}),
5_000,
).catch(() => {
throw new InitializeError({ serverID })
).catch((err) => {
log.error("initialize error", { error: err })
throw new InitializeError(
{ serverID },
{
cause: err,
},
)
})
await connection.sendNotification("initialized", {})
log.info("initialized")
log.info("initialized", {
serverID,
})
const files: {
[path: string]: number
@@ -111,9 +117,7 @@ export namespace LSPClient {
},
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path)
? input.path
: path.resolve(app.path.cwd, input.path)
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
const file = Bun.file(input.path)
const text = await file.text()
const version = files[input.path]
@@ -145,18 +149,13 @@ export namespace LSPClient {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
input.path = path.isAbsolute(input.path)
? input.path
: path.resolve(app.path.cwd, input.path)
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
log.info("waiting for diagnostics", input)
let unsub: () => void
return await withTimeout(
new Promise<void>((resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.serverID
) {
if (event.properties.path === input.path && event.properties.serverID === result.serverID) {
log.info("got diagnostics", input)
unsub?.()
resolve()
@@ -174,7 +173,6 @@ export namespace LSPClient {
log.info("shutting down", { serverID })
connection.end()
connection.dispose()
server.process.kill("SIGTERM")
log.info("shutdown", { serverID })
},
}
+4 -14
View File
@@ -46,9 +46,7 @@ export namespace LSP {
if (!file) continue
const handle = await server.spawn(App.info())
if (!handle) break
const client = await LSPClient.create(server.id, handle).catch(
() => {},
)
const client = await LSPClient.create(server.id, handle).catch((err) => log.error("", { error: err }))
if (!client) break
clients.set(server.id, client)
break
@@ -77,9 +75,7 @@ export namespace LSP {
.map((x) => x.id)
await run(async (client) => {
if (!matches.includes(client.serverID)) return
const wait = waitForDiagnostics
? client.waitForDiagnostics({ path: input })
: Promise.resolve()
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
return wait
})
@@ -97,11 +93,7 @@ export namespace LSP {
return results
}
export async function hover(input: {
file: string
line: number
character: number
}) {
export async function hover(input: { file: string; line: number; character: number }) {
return run((client) => {
return client.connection.sendRequest("textDocument/hover", {
textDocument: {
@@ -123,9 +115,7 @@ export namespace LSP {
).then((result) => result.flat() as LSP.Symbol[])
}
async function run<T>(
input: (client: LSPClient.Info) => Promise<T>,
): Promise<T[]> {
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
const clients = await state().then((x) => [...x.clients.values()])
const tasks = clients.map((x) => input(x))
return Promise.all(tasks)
+67 -30
View File
@@ -4,6 +4,8 @@ import path from "path"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -23,21 +25,14 @@ export namespace LSPServer {
id: "typescript",
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(app) {
const tsserver = await Bun.resolve(
"typescript/lib/tsserver.js",
app.path.cwd,
).catch(() => {})
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
if (!tsserver) return
const proc = spawn(
BunProc.which(),
["x", "typescript-language-server", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
env: {
...process.env,
BUN_BE_BUN: "1",
},
)
})
return {
process: proc,
initialization: {
@@ -71,10 +66,7 @@ export namespace LSPServer {
log.error("Failed to install gopls")
return
}
bin = path.join(
Global.Path.bin,
"gopls" + (process.platform === "win32" ? ".exe" : ""),
)
bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed gopls`, {
bin,
})
@@ -111,10 +103,7 @@ export namespace LSPServer {
log.error("Failed to install ruby-lsp")
return
}
bin = path.join(
Global.Path.bin,
"ruby-lsp" + (process.platform === "win32" ? ".exe" : ""),
)
bin = path.join(Global.Path.bin, "ruby-lsp" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed ruby-lsp`, {
bin,
})
@@ -129,19 +118,67 @@ export namespace LSPServer {
id: "pyright",
extensions: [".py", ".pyi"],
async spawn() {
const proc = spawn(
BunProc.which(),
["x", "pyright-langserver", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
env: {
...process.env,
BUN_BE_BUN: "1",
},
)
})
return {
process: proc,
}
},
}
export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
async spawn() {
let binary = Bun.which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
binary = path.join(
Global.Path.bin,
"elixir-ls-master",
"release",
process.platform === "win32" ? "language_server.bar" : "language_server.sh",
)
if (!(await Bun.file(binary).exists())) {
const elixir = Bun.which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
return
}
log.info("downloading elixir-ls from GitHub releases")
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
await Bun.file(zipPath).write(response)
await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
await fs.rm(zipPath, {
force: true,
recursive: true,
})
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
.quiet()
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
.env({ MIX_ENV: "prod", ...process.env })
log.info(`installed elixir-ls`, {
path: elixirLsPath,
})
}
}
return {
process: spawn(binary),
}
},
}
}
+24 -60
View File
@@ -91,8 +91,7 @@ export namespace Provider {
if (!info || info.type !== "oauth") return
if (!info.access || info.expires < Date.now()) {
const tokens = await copilot.access(info.refresh)
if (!tokens)
throw new Error("GitHub Copilot authentication expired")
if (!tokens) throw new Error("GitHub Copilot authentication expired")
await Auth.set("github-copilot", {
type: "oauth",
...tokens,
@@ -101,15 +100,9 @@ export namespace Provider {
}
let isAgentCall = false
try {
const body =
typeof init.body === "string"
? JSON.parse(init.body)
: init.body
const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body
if (body?.messages) {
isAgentCall = body.messages.some(
(msg: any) =>
msg.role && ["tool", "assistant"].includes(msg.role),
)
isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role))
}
} catch {}
const headers = {
@@ -138,14 +131,11 @@ export namespace Provider {
}
},
"amazon-bedrock": async () => {
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
return { autoload: false }
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"]) return { autoload: false }
const region = process.env["AWS_REGION"] ?? "us-east-1"
const { fromNodeProviderChain } = await import(
await BunProc.install("@aws-sdk/credential-providers")
)
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
return {
autoload: true,
options: {
@@ -157,9 +147,7 @@ export namespace Provider {
switch (regionPrefix) {
case "us": {
const modelRequiresPrefix = ["claude", "deepseek"].some((m) =>
modelID.includes(m),
)
const modelRequiresPrefix = ["claude", "deepseek"].some((m) => modelID.includes(m))
if (modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
@@ -174,25 +162,18 @@ export namespace Provider {
"eu-south-1",
"eu-south-2",
].some((r) => region.includes(r))
const modelRequiresPrefix = [
"claude",
"nova-lite",
"nova-micro",
"llama3",
"pixtral",
].some((m) => modelID.includes(m))
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
modelID.includes(m),
)
if (regionRequiresPrefix && modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
break
}
case "ap": {
const modelRequiresPrefix = [
"claude",
"nova-lite",
"nova-micro",
"nova-pro",
].some((m) => modelID.includes(m))
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
modelID.includes(m),
)
if (modelRequiresPrefix) {
regionPrefix = "apac"
modelID = `${regionPrefix}.${modelID}`
@@ -230,10 +211,7 @@ export namespace Provider {
options: Record<string, any>
}
} = {}
const models = new Map<
string,
{ info: ModelsDev.Model; language: LanguageModel }
>()
const models = new Map<string, { info: ModelsDev.Model; language: LanguageModel }>()
const sdk = new Map<string, SDK>()
log.info("init")
@@ -308,9 +286,7 @@ export namespace Provider {
database[providerID] = parsed
}
const disabled = await Config.get().then(
(cfg) => new Set(cfg.disabled_providers ?? []),
)
const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
// load env
for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
@@ -337,12 +313,7 @@ export namespace Provider {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result && (result.autoload || providers[providerID])) {
mergeProvider(
providerID,
result.options ?? {},
"custom",
result.getModel,
)
mergeProvider(providerID, result.options ?? {}, "custom", result.getModel)
}
}
@@ -379,7 +350,7 @@ export namespace Provider {
const existing = s.sdk.get(provider.id)
if (existing) return existing
const pkg = provider.npm ?? provider.id
const mod = await import(await BunProc.install(pkg, "latest"))
const mod = await import(await BunProc.install(pkg, "beta"))
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn(s.providers[provider.id]?.options)
s.sdk.set(provider.id, loaded)
@@ -406,9 +377,7 @@ export namespace Provider {
const sdk = await getSDK(provider.info)
try {
const language = provider.getModel
? await provider.getModel(sdk, modelID)
: sdk.languageModel(modelID)
const language = provider.getModel ? await provider.getModel(sdk, modelID) : sdk.languageModel(modelID)
log.info("found", { providerID, modelID })
s.models.set(key, {
info,
@@ -435,10 +404,7 @@ export namespace Provider {
export function sort(models: ModelsDev.Model[]) {
return sortBy(
models,
[
(model) => priority.findIndex((filter) => model.id.includes(filter)),
"desc",
],
[(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
[(model) => (model.id.includes("latest") ? 0 : 1), "asc"],
[(model) => model.id, "desc"],
)
@@ -449,11 +415,7 @@ export namespace Provider {
if (cfg.model) return parseModel(cfg.model)
const provider = await list()
.then((val) => Object.values(val))
.then((x) =>
x.find(
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id),
),
)
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id)))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found")
@@ -536,9 +498,11 @@ export namespace Provider {
if (schema instanceof z.ZodUnion) {
return z.union(
schema.options.map((option: z.ZodTypeAny) =>
optionalToNullable(option),
) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
schema.options.map((option: z.ZodTypeAny) => optionalToNullable(option)) as [
z.ZodTypeAny,
z.ZodTypeAny,
...z.ZodTypeAny[],
],
)
}
+9 -10
View File
@@ -1,22 +1,21 @@
import type { LanguageModelV1Prompt } from "ai"
import type { ModelMessage } from "ai"
import { unique } from "remeda"
export namespace ProviderTransform {
export function message(
msgs: LanguageModelV1Prompt,
providerID: string,
modelID: string,
) {
export function message(msgs: ModelMessage[], providerID: string, modelID: string) {
if (providerID === "anthropic" || modelID.includes("anthropic")) {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerMetadata = {
...msg.providerMetadata,
msg.providerOptions = {
...msg.providerOptions,
anthropic: {
cacheControl: { type: "ephemeral" },
},
openaiCompatible: {
cache_control: { type: "ephemeral" },
},
}
}
}
@@ -25,8 +24,8 @@ export namespace ProviderTransform {
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerMetadata = {
...msg.providerMetadata,
msg.providerOptions = {
...msg.providerOptions,
bedrock: {
cachePoint: { type: "ephemeral" },
},
+9 -17
View File
@@ -6,7 +6,6 @@ import { streamSSE } from "hono/streaming"
import { Session } from "../session"
import { resolver, validator as zValidator } from "hono-openapi/zod"
import { z } from "zod"
import { Message } from "../session/message"
import { Provider } from "../provider/provider"
import { App } from "../app/app"
import { mapValues } from "remeda"
@@ -16,6 +15,7 @@ import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
import { MessageV2 } from "../session/message-v2"
const ERRORS = {
400: {
@@ -51,12 +51,9 @@ export namespace Server {
status: 400,
})
}
return c.json(
new NamedError.Unknown({ message: err.toString() }).toObject(),
{
status: 400,
},
)
return c.json(new NamedError.Unknown({ message: err.toString() }).toObject(), {
status: 400,
})
})
.use(async (c, next) => {
log.info("request", {
@@ -407,7 +404,7 @@ export namespace Server {
description: "List of messages",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
schema: resolver(MessageV2.Info.array()),
},
},
},
@@ -433,7 +430,7 @@ export namespace Server {
description: "Created message",
content: {
"application/json": {
schema: resolver(Message.Info),
schema: resolver(MessageV2.Assistant),
},
},
},
@@ -450,7 +447,7 @@ export namespace Server {
z.object({
providerID: z.string(),
modelID: z.string(),
parts: Message.MessagePart.array(),
parts: MessageV2.UserPart.array(),
}),
),
async (c) => {
@@ -481,15 +478,10 @@ export namespace Server {
},
}),
async (c) => {
const providers = await Provider.list().then((x) =>
mapValues(x, (item) => item.info),
)
const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
return c.json({
providers: Object.values(providers),
default: mapValues(
providers,
(item) => Provider.sort(Object.values(item.models))[0].id,
),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
})
},
)
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
import z from "zod"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
import { Message } from "./message"
import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const ToolStatePending = z
.object({
status: z.literal("pending"),
})
.openapi({
ref: "ToolStatePending",
})
export type ToolStatePending = z.infer<typeof ToolStatePending>
export const ToolStateRunning = z
.object({
status: z.literal("running"),
input: z.any(),
title: z.string().optional(),
metadata: z.record(z.any()).optional(),
time: z.object({
start: z.number(),
}),
})
.openapi({
ref: "ToolStateRunning",
})
export type ToolStateRunning = z.infer<typeof ToolStateRunning>
export const ToolStateCompleted = z
.object({
status: z.literal("completed"),
input: z.record(z.any()),
output: z.string(),
title: z.string(),
metadata: z.record(z.any()),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.openapi({
ref: "ToolStateCompleted",
})
export type ToolStateCompleted = z.infer<typeof ToolStateCompleted>
export const ToolStateError = z
.object({
status: z.literal("error"),
input: z.record(z.any()),
error: z.string(),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.openapi({
ref: "ToolStateError",
})
export type ToolStateError = z.infer<typeof ToolStateError>
export const ToolState = z
.discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
.openapi({
ref: "ToolState",
})
export const TextPart = z
.object({
type: z.literal("text"),
text: z.string(),
})
.openapi({
ref: "TextPart",
})
export type TextPart = z.infer<typeof TextPart>
export const ToolPart = z
.object({
type: z.literal("tool"),
id: z.string(),
tool: z.string(),
state: ToolState,
})
.openapi({
ref: "ToolPart",
})
export type ToolPart = z.infer<typeof ToolPart>
export const FilePart = z
.object({
type: z.literal("file"),
mime: z.string(),
filename: z.string().optional(),
url: z.string(),
})
.openapi({
ref: "FilePart",
})
export type FilePart = z.infer<typeof FilePart>
export const StepStartPart = z
.object({
type: z.literal("step-start"),
})
.openapi({
ref: "StepStartPart",
})
export type StepStartPart = z.infer<typeof StepStartPart>
const Base = z.object({
id: z.string(),
sessionID: z.string(),
})
export const UserPart = z.discriminatedUnion("type", [TextPart, FilePart]).openapi({
ref: "UserMessagePart",
})
export type UserPart = z.infer<typeof UserPart>
export const User = Base.extend({
role: z.literal("user"),
parts: z.array(UserPart),
time: z.object({
created: z.number(),
}),
}).openapi({
ref: "UserMessage",
})
export type User = z.infer<typeof User>
export const AssistantPart = z.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart]).openapi({
ref: "AssistantMessagePart",
})
export type AssistantPart = z.infer<typeof AssistantPart>
export const Assistant = Base.extend({
role: z.literal("assistant"),
parts: z.array(AssistantPart),
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [Provider.AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
.optional(),
system: z.string().array(),
modelID: z.string(),
providerID: z.string(),
path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
}).openapi({
ref: "AssistantMessage",
})
export type Assistant = z.infer<typeof Assistant>
export const Info = z.discriminatedUnion("role", [User, Assistant]).openapi({
ref: "Message",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
"message.updated",
z.object({
info: Info,
}),
),
Removed: Bus.event(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
}),
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({
part: AssistantPart,
sessionID: z.string(),
messageID: z.string(),
}),
),
}
export function fromV1(v1: Message.Info) {
if (v1.role === "assistant") {
const result: Assistant = {
id: v1.id,
sessionID: v1.metadata.sessionID,
role: "assistant",
time: {
created: v1.metadata.time.created,
completed: v1.metadata.time.completed,
},
cost: v1.metadata.assistant!.cost,
path: v1.metadata.assistant!.path,
summary: v1.metadata.assistant!.summary,
tokens: v1.metadata.assistant!.tokens,
modelID: v1.metadata.assistant!.modelID,
providerID: v1.metadata.assistant!.providerID,
system: v1.metadata.assistant!.system,
error: v1.metadata.error,
parts: v1.parts.flatMap((part): AssistantPart[] => {
if (part.type === "text") {
return [
{
type: "text",
text: part.text,
},
]
}
if (part.type === "step-start") {
return [
{
type: "step-start",
},
]
}
if (part.type === "tool-invocation") {
return [
{
type: "tool",
id: part.toolInvocation.toolCallId,
tool: part.toolInvocation.toolName,
state: (() => {
if (part.toolInvocation.state === "partial-call") {
return {
status: "pending",
}
}
const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] ?? {}
if (part.toolInvocation.state === "call") {
return {
status: "running",
input: part.toolInvocation.args,
time: {
start: time?.start,
},
}
}
if (part.toolInvocation.state === "result") {
return {
status: "completed",
input: part.toolInvocation.args,
output: part.toolInvocation.result,
title,
time,
metadata,
}
}
throw new Error("unknown tool invocation state")
})(),
},
]
}
return []
}),
}
return result
}
if (v1.role === "user") {
const result: User = {
id: v1.id,
sessionID: v1.metadata.sessionID,
role: "user",
time: {
created: v1.metadata.time.created,
},
parts: v1.parts.flatMap((part): UserPart[] => {
if (part.type === "text") {
return [
{
type: "text",
text: part.text,
},
]
}
if (part.type === "file") {
return [
{
type: "file",
mime: part.mediaType,
filename: part.filename,
url: part.url,
},
]
}
return []
}),
}
return result
}
}
export function toModelMessage(input: Info[]): ModelMessage[] {
const result: UIMessage[] = []
for (const msg of input) {
if (msg.parts.length === 0) continue
if (msg.role === "user") {
result.push({
id: msg.id,
role: "user",
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
if (part.type === "text")
return [
{
type: "text",
text: part.text,
},
]
if (part.type === "file")
return [
{
type: "file",
url: part.url,
mediaType: part.mime,
filename: part.filename,
},
]
return []
}),
})
}
if (msg.role === "assistant") {
result.push({
id: msg.id,
role: "assistant",
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
if (part.type === "text")
return [
{
type: "text",
text: part.text,
},
]
if (part.type === "step-start")
return [
{
type: "step-start",
},
]
if (part.type === "tool") {
if (part.state.status === "completed")
return [
{
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.id,
input: part.state.input,
output: part.state.output,
},
]
if (part.state.status === "error")
return [
{
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.id,
input: part.state.input,
errorText: part.state.error,
},
]
}
return []
}),
})
}
}
return convertToModelMessages(result)
}
}
+5 -42
View File
@@ -1,13 +1,9 @@
import z from "zod"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
export namespace Message {
export const OutputLengthError = NamedError.create(
"MessageOutputLengthError",
z.object({}),
)
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const ToolCall = z
.object({
@@ -49,11 +45,9 @@ export namespace Message {
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocation = z
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
.openapi({
ref: "ToolInvocation",
})
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).openapi({
ref: "ToolInvocation",
})
export type ToolInvocation = z.infer<typeof ToolInvocation>
export const TextPart = z
@@ -122,14 +116,7 @@ export namespace Message {
export type StepStartPart = z.infer<typeof StepStartPart>
export const MessagePart = z
.discriminatedUnion("type", [
TextPart,
ReasoningPart,
ToolInvocationPart,
SourceUrlPart,
FilePart,
StepStartPart,
])
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
.openapi({
ref: "MessagePart",
})
@@ -197,28 +184,4 @@ export namespace Message {
ref: "Message",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
"message.updated",
z.object({
info: Info,
}),
),
Removed: Bus.event(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
}),
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({
part: MessagePart,
sessionID: z.string(),
messageID: z.string(),
}),
),
}
}
+3 -8
View File
@@ -53,9 +53,7 @@ export namespace Share {
export const URL =
process.env["OPENCODE_API"] ??
(Installation.isSnapshot() || Installation.isDev()
? "https://api.dev.opencode.ai"
: "https://api.opencode.ai")
(Installation.isSnapshot() || Installation.isDev() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
export async function create(sessionID: string) {
return fetch(`${URL}/share_create`, {
@@ -66,13 +64,10 @@ export namespace Share {
.then((x) => x as { url: string; secret: string })
}
export async function remove(id: string) {
const share = await Session.getShare(id).catch(() => {})
if (!share) return
const { secret } = share
export async function remove(sessionID: string, secret: string) {
return fetch(`${URL}/share_delete`, {
method: "POST",
body: JSON.stringify({ id, secret }),
body: JSON.stringify({ sessionID, secret }),
}).then((x) => x.json())
}
}
+41 -61
View File
@@ -1,14 +1,7 @@
import { App } from "../app/app"
import {
add,
commit,
init,
checkout,
statusMatrix,
remove,
} from "isomorphic-git"
import { $ } from "bun"
import path from "path"
import fs from "fs"
import fs from "fs/promises"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
@@ -16,66 +9,53 @@ export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
return
log.info("creating snapshot")
const app = App.info()
const git = gitdir(sessionID)
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: app.git ? undefined : 1000,
})
// not a git repo and too big to snapshot
if (!app.git && files.length === 1000) return
await init({
dir: app.path.cwd,
gitdir: git,
fs,
})
const status = await statusMatrix({
fs,
gitdir: git,
dir: app.path.cwd,
})
await add({
fs,
gitdir: git,
parallel: true,
dir: app.path.cwd,
filepath: files,
})
for (const [file, _head, workdir, stage] of status) {
if (workdir === 0 && stage === 1) {
log.info("remove", { file })
await remove({
fs,
gitdir: git,
dir: app.path.cwd,
filepath: file,
})
}
// not a git repo, check if too big to snapshot
if (!app.git) {
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: 1000,
})
log.info("found files", { count: files.length })
if (files.length > 1000) return
}
const result = await commit({
fs,
gitdir: git,
dir: app.path.cwd,
message: "snapshot",
author: {
name: "opencode",
email: "mail@opencode.ai",
},
})
log.info("commit", { result })
return result
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: app.path.root,
})
.quiet()
.nothrow()
log.info("initialized")
}
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
log.info("added files")
const result =
await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode <mail@opencode.ai>"`
.quiet()
.cwd(app.path.cwd)
.nothrow()
log.info("commit")
const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/)
if (!match) return
return match![1]
}
export async function restore(sessionID: string, commit: string) {
log.info("restore", { commit })
const app = App.info()
await checkout({
fs,
gitdir: gitdir(sessionID),
dir: app.path.cwd,
ref: commit,
force: true,
})
const git = gitdir(sessionID)
await $`git --git-dir=${git} checkout ${commit} --force`.quiet().cwd(app.path.root)
}
function gitdir(sessionID: string) {
+46 -12
View File
@@ -4,44 +4,77 @@ import { Bus } from "../bus"
import path from "path"
import z from "zod"
import fs from "fs/promises"
import { MessageV2 } from "../session/message-v2"
export namespace Storage {
const log = Log.create({ service: "storage" })
export const Event = {
Write: Bus.event(
"storage.write",
z.object({ key: z.string(), content: z.any() }),
),
Write: Bus.event("storage.write", z.object({ key: z.string(), content: z.any() })),
}
const state = App.state("storage", () => {
type Migration = (dir: string) => Promise<void>
const MIGRATIONS: Migration[] = [
async (dir: string) => {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
const content = await Bun.file(file).json()
if (!content.metadata) continue
log.info("migrating to v2 message", { file })
try {
const result = MessageV2.fromV1(content)
await Bun.write(file, JSON.stringify(result, null, 2))
} catch (e) {
await fs.rename(file, file.replace("storage", "broken"))
}
}
},
]
const state = App.state("storage", async () => {
const app = App.info()
const dir = path.join(app.path.data, "storage")
log.info("init", { path: dir })
const migration = await Bun.file(path.join(dir, "migration"))
.json()
.then((x) => parseInt(x))
.catch(() => 0)
for (let index = migration; index < MIGRATIONS.length; index++) {
log.info("running migration", { index })
const migration = MIGRATIONS[index]
await migration(dir)
await Bun.write(path.join(dir, "migration"), (index + 1).toString())
}
return {
dir,
}
})
export async function remove(key: string) {
const target = path.join(state().dir, key + ".json")
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key + ".json")
await fs.unlink(target).catch(() => {})
}
export async function removeDir(key: string) {
const target = path.join(state().dir, key)
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key)
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
}
export async function readJSON<T>(key: string) {
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
const dir = await state().then((x) => x.dir)
return Bun.file(path.join(dir, key + ".json")).json() as Promise<T>
}
export async function writeJSON<T>(key: string, content: T) {
const target = path.join(state().dir, key + ".json")
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key + ".json")
const tmp = target + Date.now() + ".tmp"
await Bun.write(tmp, JSON.stringify(content))
await Bun.write(tmp, JSON.stringify(content, null, 2))
await fs.rename(tmp, target).catch(() => {})
await fs.unlink(tmp).catch(() => {})
Bus.publish(Event.Write, { key, content })
@@ -49,9 +82,10 @@ export namespace Storage {
const glob = new Bun.Glob("**/*")
export async function* list(prefix: string) {
const dir = await state().then((x) => x.dir)
try {
for await (const item of glob.scan({
cwd: path.join(state().dir, prefix),
cwd: path.join(dir, prefix),
onlyFiles: true,
})) {
const result = path.join(prefix, item.slice(0, -5))
+3 -36
View File
@@ -4,25 +4,6 @@ import DESCRIPTION from "./bash.txt"
import { App } from "../app/app"
const MAX_OUTPUT_LENGTH = 30000
const BANNED_COMMANDS = [
"alias",
"curl",
"curlie",
"wget",
"axel",
"aria2c",
"nc",
"telnet",
"lynx",
"w3m",
"links",
"httpie",
"xh",
"http-prompt",
"chrome",
"firefox",
"safari",
]
const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
@@ -31,12 +12,7 @@ export const BashTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z
.number()
.min(0)
.max(MAX_TIMEOUT)
.describe("Optional timeout in milliseconds")
.optional(),
timeout: z.number().min(0).max(MAX_TIMEOUT).describe("Optional timeout in milliseconds").optional(),
description: z
.string()
.describe(
@@ -45,8 +21,6 @@ export const BashTool = Tool.define({
}),
async execute(params, ctx) {
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
if (BANNED_COMMANDS.some((item) => params.command.startsWith(item)))
throw new Error(`Command '${params.command}' is not allowed`)
const process = Bun.spawn({
cmd: ["bash", "-c", params.command],
@@ -62,21 +36,14 @@ export const BashTool = Tool.define({
const stderr = await new Response(process.stderr).text()
return {
title: params.command,
metadata: {
stderr,
stdout,
exit: process.exitCode,
description: params.description,
title: params.command,
},
output: [
`<stdout>`,
stdout ?? "",
`</stdout>`,
`<stderr>`,
stderr ?? "",
`</stderr>`,
].join("\n"),
output: [`<stdout>`, stdout ?? "", `</stdout>`, `<stderr>`, stderr ?? "", `</stderr>`].join("\n"),
}
},
})
+18 -57
View File
@@ -20,15 +20,8 @@ export const EditTool = Tool.define({
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z
.string()
.describe(
"The text to replace it with (must be different from old_string)",
),
replaceAll: z
.boolean()
.optional()
.describe("Replace all occurrences of old_string (default false)"),
newString: z.string().describe("The text to replace it with (must be different from old_string)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of old_string (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
@@ -40,9 +33,7 @@ export const EditTool = Tool.define({
}
const app = App.info()
const filepath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(app.path.cwd, params.filePath)
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
await Permission.ask({
id: "edit",
@@ -70,17 +61,11 @@ export const EditTool = Tool.define({
const file = Bun.file(filepath)
const stats = await file.stat().catch(() => {})
if (!stats) throw new Error(`File ${filepath} not found`)
if (stats.isDirectory())
throw new Error(`Path is a directory, not a file: ${filepath}`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`)
await FileTime.assert(ctx.sessionID, filepath)
contentOld = await file.text()
contentNew = replace(
contentOld,
params.oldString,
params.newString,
params.replaceAll,
)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
file: filepath,
@@ -88,9 +73,7 @@ export const EditTool = Tool.define({
contentNew = await file.text()
})()
const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
FileTime.read(ctx.sessionID, filepath)
@@ -110,17 +93,14 @@ export const EditTool = Tool.define({
metadata: {
diagnostics,
diff,
title: `${path.relative(app.path.root, filepath)}`,
},
title: `${path.relative(app.path.root, filepath)}`,
output,
}
},
})
export type Replacer = (
content: string,
find: string,
) => Generator<string, void, unknown>
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
@@ -208,10 +188,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (
content,
find,
) {
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(find)
@@ -229,9 +206,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+")
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
@@ -270,9 +245,7 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
}),
)
return lines
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
.join("\n")
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
}
const normalizedFind = removeIndentation(find)
@@ -423,10 +396,7 @@ export const ContextAwareReplacer: Replacer = function* (content, find) {
}
}
if (
totalNonEmptyLines === 0 ||
matchingLines / totalNonEmptyLines >= 0.5
) {
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
yield block
break // Only match the first occurrence
}
@@ -473,12 +443,7 @@ function trimDiff(diff: string): string {
return trimmedLines.join("\n")
}
export function replace(
content: string,
oldString: string,
newString: string,
replaceAll = false,
): string {
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
if (oldString === newString) {
throw new Error("oldString and newString must be different")
}
@@ -489,10 +454,10 @@ export function replace(
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
// EscapeNormalizedReplacer,
// TrimmedBoundaryReplacer,
// ContextAwareReplacer,
// MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
@@ -502,11 +467,7 @@ export function replace(
}
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue
return (
content.substring(0, index) +
newString +
content.substring(index + search.length)
)
return content.substring(0, index) + newString + content.substring(index + search.length)
}
}
throw new Error("oldString not found in content or was found multiple times")
+3 -7
View File
@@ -20,9 +20,7 @@ export const GlobTool = Tool.define({
async execute(params) {
const app = App.info()
let search = params.path ?? app.path.cwd
search = path.isAbsolute(search)
? search
: path.resolve(app.path.cwd, search)
search = path.isAbsolute(search) ? search : path.resolve(app.path.cwd, search)
const limit = 100
const files = []
@@ -53,17 +51,15 @@ export const GlobTool = Tool.define({
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
)
output.push("(Results are truncated. Consider using a more specific path or pattern.)")
}
}
return {
title: path.relative(app.path.root, search),
metadata: {
count: files.length,
truncated,
title: path.relative(app.path.root, search),
},
output: output.join("\n"),
}
+9 -21
View File
@@ -9,21 +9,9 @@ export const GrepTool = Tool.define({
id: "grep",
description: DESCRIPTION,
parameters: z.object({
pattern: z
.string()
.describe("The regex pattern to search for in file contents"),
path: z
.string()
.optional()
.describe(
"The directory to search in. Defaults to the current working directory.",
),
include: z
.string()
.optional()
.describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
),
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
async execute(params) {
if (!params.pattern) {
@@ -51,7 +39,8 @@ export const GrepTool = Tool.define({
if (exitCode === 1) {
return {
metadata: { matches: 0, truncated: false, title: params.pattern },
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
@@ -93,7 +82,8 @@ export const GrepTool = Tool.define({
if (finalMatches.length === 0) {
return {
metadata: { matches: 0, truncated: false, title: params.pattern },
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
@@ -114,16 +104,14 @@ export const GrepTool = Tool.define({
if (truncated) {
outputLines.push("")
outputLines.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
)
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
}
return {
title: params.pattern,
metadata: {
matches: finalMatches.length,
truncated,
title: params.pattern,
},
output: outputLines.join("\n"),
}
+4 -13
View File
@@ -24,16 +24,8 @@ export const ListTool = Tool.define({
id: "list",
description: DESCRIPTION,
parameters: z.object({
path: z
.string()
.describe(
"The absolute path to the directory to list (must be absolute, not relative)",
)
.optional(),
ignore: z
.array(z.string())
.describe("List of glob patterns to ignore")
.optional(),
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params) {
const app = App.info()
@@ -44,8 +36,7 @@ export const ListTool = Tool.define({
for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
if (IGNORE_PATTERNS.some((p) => file.includes(p))) continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file))) continue
files.push(file)
if (files.length >= LIMIT) break
}
@@ -99,10 +90,10 @@ export const ListTool = Tool.define({
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(app.path.root, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
title: path.relative(app.path.root, searchPath),
},
output,
}
@@ -13,20 +13,16 @@ export const LspDiagnosticTool = Tool.define({
}),
execute: async (args) => {
const app = App.info()
const normalized = path.isAbsolute(args.path)
? args.path
: path.join(app.path.cwd, args.path)
const normalized = path.isAbsolute(args.path) ? args.path : path.join(app.path.cwd, args.path)
await LSP.touchFile(normalized, true)
const diagnostics = await LSP.diagnostics()
const file = diagnostics[normalized]
return {
title: path.relative(app.path.root, normalized),
metadata: {
diagnostics,
title: path.relative(app.path.root, normalized),
},
output: file?.length
? file.map(LSP.Diagnostic.pretty).join("\n")
: "No errors found",
output: file?.length ? file.map(LSP.Diagnostic.pretty).join("\n") : "No errors found",
}
},
})
+2 -9
View File
@@ -15,9 +15,7 @@ export const LspHoverTool = Tool.define({
}),
execute: async (args) => {
const app = App.info()
const file = path.isAbsolute(args.file)
? args.file
: path.join(app.path.cwd, args.file)
const file = path.isAbsolute(args.file) ? args.file : path.join(app.path.cwd, args.file)
await LSP.touchFile(file, true)
const result = await LSP.hover({
...args,
@@ -25,14 +23,9 @@ export const LspHoverTool = Tool.define({
})
return {
title: path.relative(app.path.root, file) + ":" + args.line + ":" + args.character,
metadata: {
result,
title:
path.relative(app.path.root, file) +
":" +
args.line +
":" +
args.character,
},
output: JSON.stringify(result, null, 2),
}
+2 -4
View File
@@ -10,9 +10,7 @@ export const MultiEditTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(EditTool.parameters)
.describe("Array of edit operations to perform sequentially on the file"),
edits: z.array(EditTool.parameters).describe("Array of edit operations to perform sequentially on the file"),
}),
async execute(params, ctx) {
const results = []
@@ -30,9 +28,9 @@ export const MultiEditTool = Tool.define({
}
const app = App.info()
return {
title: path.relative(app.path.root, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
title: path.relative(app.path.root, params.filePath),
},
output: results.at(-1)!.output,
}
+10 -37
View File
@@ -6,9 +6,7 @@ import { FileTime } from "../file/time"
import DESCRIPTION from "./patch.txt"
const PatchParams = z.object({
patchText: z
.string()
.describe("The full patch text that describes all changes to be made"),
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
interface Change {
@@ -42,10 +40,7 @@ function identifyFilesNeeded(patchText: string): string[] {
const files: string[] = []
const lines = patchText.split("\n")
for (const line of lines) {
if (
line.startsWith("*** Update File:") ||
line.startsWith("*** Delete File:")
) {
if (line.startsWith("*** Update File:") || line.startsWith("*** Delete File:")) {
const filePath = line.split(":", 2)[1]?.trim()
if (filePath) files.push(filePath)
}
@@ -65,10 +60,7 @@ function identifyFilesAdded(patchText: string): string[] {
return files
}
function textToPatch(
patchText: string,
_currentFiles: Record<string, string>,
): [PatchOperation[], number] {
function textToPatch(patchText: string, _currentFiles: Record<string, string>): [PatchOperation[], number] {
const operations: PatchOperation[] = []
const lines = patchText.split("\n")
let i = 0
@@ -93,11 +85,7 @@ function textToPatch(
const changes: PatchChange[] = []
i++
while (
i < lines.length &&
!lines[i].startsWith("@@") &&
!lines[i].startsWith("***")
) {
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
const changeLine = lines[i]
if (changeLine.startsWith(" ")) {
changes.push({ type: "keep", content: changeLine.substring(1) })
@@ -151,10 +139,7 @@ function textToPatch(
return [operations, fuzz]
}
function patchToCommit(
operations: PatchOperation[],
currentFiles: Record<string, string>,
): Commit {
function patchToCommit(operations: PatchOperation[], currentFiles: Record<string, string>): Commit {
const changes: Record<string, Change> = {}
for (const op of operations) {
@@ -173,9 +158,7 @@ function patchToCommit(
const lines = originalContent.split("\n")
for (const hunk of op.hunks) {
const contextIndex = lines.findIndex((line) =>
line.includes(hunk.contextLine),
)
const contextIndex = lines.findIndex((line) => line.includes(hunk.contextLine))
if (contextIndex === -1) {
throw new Error(`Context line not found: ${hunk.contextLine}`)
}
@@ -204,11 +187,7 @@ function patchToCommit(
return { changes }
}
function generateDiff(
oldContent: string,
newContent: string,
filePath: string,
): [string, number, number] {
function generateDiff(oldContent: string, newContent: string, filePath: string): [string, number, number] {
// Mock implementation - would need actual diff generation
const lines1 = oldContent.split("\n")
const lines2 = newContent.split("\n")
@@ -296,9 +275,7 @@ export const PatchTool = Tool.define({
// Process the patch
const [patch, fuzz] = textToPatch(params.patchText, currentFiles)
if (fuzz > 3) {
throw new Error(
`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
)
throw new Error(`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`)
}
// Convert patch to commit
@@ -343,11 +320,7 @@ export const PatchTool = Tool.define({
const newContent = change.new_content || ""
// Calculate diff statistics
const [, additions, removals] = generateDiff(
oldContent,
newContent,
filePath,
)
const [, additions, removals] = generateDiff(oldContent, newContent, filePath)
totalAdditions += additions
totalRemovals += removals
@@ -358,11 +331,11 @@ export const PatchTool = Tool.define({
const output = result
return {
title: `${filesToRead.length} files`,
metadata: {
changed: changedFiles,
additions: totalAdditions,
removals: totalRemovals,
title: `${filesToRead.length} files`,
},
output,
}
+9 -27
View File
@@ -16,14 +16,8 @@ export const ReadTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The path to the file to read"),
offset: z
.number()
.describe("The line number to start reading from (0-based)")
.optional(),
limit: z
.number()
.describe("The number of lines to read (defaults to 2000)")
.optional(),
offset: z.number().describe("The line number to start reading from (0-based)").optional(),
limit: z.number().describe("The number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
let filePath = params.filePath
@@ -40,16 +34,13 @@ export const ReadTool = Tool.define({
const suggestions = dirEntries
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) ||
base.toLowerCase().includes(entry.toLowerCase()),
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
)
.map((entry) => path.join(dir, entry))
.slice(0, 3)
if (suggestions.length > 0) {
throw new Error(
`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
)
throw new Error(`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
}
throw new Error(`File not found: ${filePath}`)
@@ -57,21 +48,14 @@ export const ReadTool = Tool.define({
const stats = await file.stat()
if (stats.size > MAX_READ_SIZE)
throw new Error(
`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`,
)
throw new Error(`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`)
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset || 0
const isImage = isImageFile(filePath)
if (isImage)
throw new Error(
`This is an image file of type: ${isImage}\nUse a different tool to process images`,
)
if (isImage) throw new Error(`This is an image file of type: ${isImage}\nUse a different tool to process images`)
const lines = await file.text().then((text) => text.split("\n"))
const raw = lines.slice(offset, offset + limit).map((line) => {
return line.length > MAX_LINE_LENGTH
? line.substring(0, MAX_LINE_LENGTH) + "..."
: line
return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
})
const content = raw.map((line, index) => {
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
@@ -82,9 +66,7 @@ export const ReadTool = Tool.define({
output += content.join("\n")
if (lines.length > offset + content.length) {
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${
offset + content.length
})`
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${offset + content.length})`
}
output += "\n</file>"
@@ -93,10 +75,10 @@ export const ReadTool = Tool.define({
FileTime.read(ctx.sessionID, filePath)
return {
title: path.relative(App.info().path.root, filePath),
output,
metadata: {
preview,
title: path.relative(App.info().path.root, filePath),
},
}
},
+14 -19
View File
@@ -3,41 +3,36 @@ import DESCRIPTION from "./task.txt"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
import { Message } from "../session/message"
import { MessageV2 } from "../session/message-v2"
export const TaskTool = Tool.define({
id: "task",
description: DESCRIPTION,
parameters: z.object({
description: z
.string()
.describe("A short (3-5 words) description of the task"),
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
}),
async execute(params, ctx) {
const session = await Session.create(ctx.sessionID)
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
const metadata = msg.metadata.assistant!
const msg = (await Session.getMessage(ctx.sessionID, ctx.messageID)) as MessageV2.Assistant
function summary(input: Message.Info) {
function summary(input: MessageV2.Info) {
const result = []
for (const part of input.parts) {
if (part.type === "tool-invocation") {
result.push({
toolInvocation: part.toolInvocation,
metadata: input.metadata.tool[part.toolInvocation.toolCallId],
})
if (part.type === "tool" && part.state.status === "completed") {
result.push(part)
}
}
return result
}
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
if (evt.properties.info.metadata.sessionID !== session.id) return
const unsub = Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
if (evt.properties.info.sessionID !== session.id) return
ctx.metadata({
title: params.description,
summary: summary(evt.properties.info),
metadata: {
summary: summary(evt.properties.info),
},
})
})
@@ -46,8 +41,8 @@ export const TaskTool = Tool.define({
})
const result = await Session.chat({
sessionID: session.id,
modelID: metadata.modelID,
providerID: metadata.providerID,
modelID: msg.modelID,
providerID: msg.providerID,
parts: [
{
type: "text",
@@ -57,8 +52,8 @@ export const TaskTool = Tool.define({
})
unsub()
return {
title: params.description,
metadata: {
title: params.description,
summary: summary(result),
},
output: result.parts.findLast((x) => x.type === "text")!.text,
+4 -8
View File
@@ -5,12 +5,8 @@ import { App } from "../app/app"
const TodoInfo = z.object({
content: z.string().min(1).describe("Brief description of the task"),
status: z
.enum(["pending", "in_progress", "completed"])
.describe("Current status of the task"),
priority: z
.enum(["high", "medium", "low"])
.describe("Priority level of the task"),
status: z.enum(["pending", "in_progress", "completed"]).describe("Current status of the task"),
priority: z.enum(["high", "medium", "low"]).describe("Priority level of the task"),
id: z.string().describe("Unique identifier for the todo item"),
})
type TodoInfo = z.infer<typeof TodoInfo>
@@ -32,9 +28,9 @@ export const TodoWriteTool = Tool.define({
const todos = state()
todos[opts.sessionID] = params.todos
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
todos: params.todos,
},
}
@@ -48,9 +44,9 @@ export const TodoReadTool = Tool.define({
async execute(_params, opts) {
const todos = state()[opts.sessionID] ?? []
return {
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
metadata: {
todos,
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
},
output: JSON.stringify(todos, null, 2),
}
+6 -10
View File
@@ -2,19 +2,15 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"
export namespace Tool {
interface Metadata {
title: string
[key: string]: any
}
export type Context<M extends Metadata = Metadata> = {
sessionID: string
messageID: string
abort: AbortSignal
metadata(meta: M): void
metadata(input: { title?: string; metadata?: M }): void
}
export interface Info<
Parameters extends StandardSchemaV1 = StandardSchemaV1,
M extends Metadata = Metadata,
> {
export interface Info<Parameters extends StandardSchemaV1 = StandardSchemaV1, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
@@ -22,15 +18,15 @@ export namespace Tool {
args: StandardSchemaV1.InferOutput<Parameters>,
ctx: Context,
): Promise<{
title: string
metadata: M
output: string
}>
}
export function define<
Parameters extends StandardSchemaV1,
Result extends Metadata,
>(input: Info<Parameters, Result>): Info<Parameters, Result> {
export function define<Parameters extends StandardSchemaV1, Result extends Metadata>(
input: Info<Parameters, Result>,
): Info<Parameters, Result> {
return input
}
}
+17 -41
View File
@@ -14,9 +14,7 @@ export const WebFetchTool = Tool.define({
url: z.string().describe("The URL to fetch content from"),
format: z
.enum(["text", "markdown", "html"])
.describe(
"The format to return the content in (text, markdown, or html)",
),
.describe("The format to return the content in (text, markdown, or html)"),
timeout: z
.number()
.min(0)
@@ -26,17 +24,11 @@ export const WebFetchTool = Tool.define({
}),
async execute(params, ctx) {
// Validate URL
if (
!params.url.startsWith("http://") &&
!params.url.startsWith("https://")
) {
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
}
const timeout = Math.min(
(params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000,
MAX_TIMEOUT,
)
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
@@ -46,8 +38,7 @@ export const WebFetchTool = Tool.define({
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
})
@@ -79,16 +70,14 @@ export const WebFetchTool = Tool.define({
const text = await extractTextFromHTML(content)
return {
output: text,
metadata: {
title,
},
title,
metadata: {},
}
}
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
case "markdown":
@@ -96,32 +85,28 @@ export const WebFetchTool = Tool.define({
const markdown = convertHTMLToMarkdown(content)
return {
output: markdown,
metadata: {
title,
},
title,
metadata: {},
}
}
return {
output: "```\n" + content + "\n```",
metadata: {
title,
},
title,
metadata: {},
}
case "html":
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
default:
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
}
},
@@ -143,16 +128,7 @@ async function extractTextFromHTML(html: string) {
.on("*", {
element(element) {
// Reset skip flag when entering other elements
if (
![
"script",
"style",
"noscript",
"iframe",
"object",
"embed",
].includes(element.tagName)
) {
if (!["script", "style", "noscript", "iframe", "object", "embed"].includes(element.tagName)) {
skipContent = false
}
},
+4 -12
View File
@@ -13,18 +13,12 @@ export const WriteTool = Tool.define({
id: "write",
description: DESCRIPTION,
parameters: z.object({
filePath: z
.string()
.describe(
"The absolute path to the file to write (must be absolute, not relative)",
),
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
}),
async execute(params, ctx) {
const app = App.info()
const filepath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(app.path.cwd, params.filePath)
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
const file = Bun.file(filepath)
const exists = await file.exists()
@@ -33,9 +27,7 @@ export const WriteTool = Tool.define({
await Permission.ask({
id: "write",
sessionID: ctx.sessionID,
title: exists
? "Overwrite this file: " + filepath
: "Create new file: " + filepath,
title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath,
metadata: {
filePath: filepath,
content: params.content,
@@ -62,11 +54,11 @@ export const WriteTool = Tool.define({
}
return {
title: path.relative(app.path.root, filepath),
metadata: {
diagnostics,
filepath,
exists: exists,
title: path.relative(app.path.root, filepath),
},
output,
}
+1 -4
View File
@@ -7,10 +7,7 @@ export abstract class NamedError extends Error {
abstract schema(): ZodSchema
abstract toObject(): { name: string; data: any }
static create<Name extends string, Data extends ZodSchema>(
name: Name,
data: Data,
) {
static create<Name extends string, Data extends ZodSchema>(name: Name, data: Data) {
const schema = z
.object({
name: z.literal(name),
+3 -12
View File
@@ -19,10 +19,7 @@ export namespace Log {
await fs.mkdir(dir, { recursive: true })
cleanup(dir)
if (options.print) return
logpath = path.join(
dir,
new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
logpath = path.join(dir, new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log")
const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer()
@@ -43,9 +40,7 @@ export namespace Log {
const filesToDelete = files.slice(0, -10)
await Promise.all(
filesToDelete.map((file) => fs.unlink(file).catch(() => {})),
)
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
}
let last = Date.now()
@@ -63,11 +58,7 @@ export namespace Log {
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return (
[next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message]
.filter(Boolean)
.join(" ") + "\n"
)
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result = {
info(message?: any, extra?: Record<string, any>) {
+20 -91
View File
@@ -17,12 +17,7 @@ const testCases: TestCase[] = [
replace: 'console.log("universe");',
},
{
content: [
"if (condition) {",
" doSomething();",
" doSomethingElse();",
"}",
].join("\n"),
content: ["if (condition) {", " doSomething();", " doSomethingElse();", "}"].join("\n"),
find: [" doSomething();", " doSomethingElse();"].join("\n"),
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
},
@@ -53,15 +48,8 @@ const testCases: TestCase[] = [
" return result;",
"}",
].join("\n"),
find: [
"function calculate(a, b) {",
" // different middle content",
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join(
"\n",
),
find: ["function calculate(a, b) {", " // different middle content", " return result;", "}"].join("\n"),
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join("\n"),
},
{
content: [
@@ -76,13 +64,7 @@ const testCases: TestCase[] = [
"}",
].join("\n"),
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
replace: [
"class MyClass {",
" constructor() {",
" this.value = 42;",
" }",
"}",
].join("\n"),
replace: ["class MyClass {", " constructor() {", " this.value = 42;", " }", "}"].join("\n"),
},
// WhitespaceNormalizedReplacer cases
@@ -104,48 +86,21 @@ const testCases: TestCase[] = [
// IndentationFlexibleReplacer cases
{
content: [
" function nested() {",
' console.log("deeply nested");',
" return true;",
" }",
].join("\n"),
find: [
"function nested() {",
' console.log("deeply nested");',
" return true;",
"}",
].join("\n"),
replace: [
"function nested() {",
' console.log("updated");',
" return false;",
"}",
].join("\n"),
content: [" function nested() {", ' console.log("deeply nested");', " return true;", " }"].join(
"\n",
),
find: ["function nested() {", ' console.log("deeply nested");', " return true;", "}"].join("\n"),
replace: ["function nested() {", ' console.log("updated");', " return false;", "}"].join("\n"),
},
{
content: [
" if (true) {",
' console.log("level 1");',
' console.log("level 2");',
" }",
].join("\n"),
find: [
"if (true) {",
'console.log("level 1");',
' console.log("level 2");',
"}",
].join("\n"),
content: [" if (true) {", ' console.log("level 1");', ' console.log("level 2");', " }"].join("\n"),
find: ["if (true) {", 'console.log("level 1");', ' console.log("level 2");', "}"].join("\n"),
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
},
// replaceAll option cases
{
content: [
'console.log("test");',
'console.log("test");',
'console.log("test");',
].join("\n"),
content: ['console.log("test");', 'console.log("test");', 'console.log("test");'].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: true,
@@ -213,9 +168,7 @@ const testCases: TestCase[] = [
// MultiOccurrenceReplacer cases (with replaceAll)
{
content: ["debug('start');", "debug('middle');", "debug('end');"].join(
"\n",
),
content: ["debug('start');", "debug('middle');", "debug('end');"].join("\n"),
find: "debug",
replace: "log",
all: true,
@@ -239,9 +192,7 @@ const testCases: TestCase[] = [
replace: "const value = 24;",
},
{
content: ["", " if (condition) {", " doSomething();", " }", ""].join(
"\n",
),
content: ["", " if (condition) {", " doSomething();", " }", ""].join("\n"),
find: ["if (condition) {", " doSomething();", "}"].join("\n"),
replace: ["if (condition) {", " doNothing();", "}"].join("\n"),
},
@@ -262,9 +213,7 @@ const testCases: TestCase[] = [
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join(
"\n",
),
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join("\n"),
},
{
content: [
@@ -278,15 +227,8 @@ const testCases: TestCase[] = [
" }",
"}",
].join("\n"),
find: [
"class TestClass {",
" // different implementation",
" // with multiple lines",
"}",
].join("\n"),
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join(
"\n",
),
find: ["class TestClass {", " // different implementation", " // with multiple lines", "}"].join("\n"),
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join("\n"),
},
// Combined edge cases for new replacers
@@ -296,9 +238,7 @@ const testCases: TestCase[] = [
replace: 'console.log("updated");',
},
{
content: [" ", "function test() {", " return 'value';", "}", " "].join(
"\n",
),
content: [" ", "function test() {", " return 'value';", "}", " "].join("\n"),
find: ["function test() {", "return 'value';", "}"].join("\n"),
replace: ["function test() {", "return 'new value';", "}"].join("\n"),
},
@@ -346,13 +286,7 @@ const testCases: TestCase[] = [
// ContextAwareReplacer - test with trailing newline in find string
{
content: [
"class Test {",
" method1() {",
" return 1;",
" }",
"}",
].join("\n"),
content: ["class Test {", " method1() {", " return 1;", " }", "}"].join("\n"),
find: [
"class Test {",
" // different content",
@@ -401,12 +335,7 @@ describe("EditTool Replacers", () => {
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
}).toThrow()
} else {
const result = replace(
testCase.content,
testCase.find,
testCase.replace,
testCase.all,
)
const result = replace(testCase.content, testCase.find, testCase.replace, testCase.all)
expect(result).toContain(testCase.replace)
}
})
+1 -4
View File
@@ -42,10 +42,7 @@ describe("tool.glob", () => {
describe("tool.ls", () => {
test("basic", async () => {
const result = await App.provide({ cwd: process.cwd() }, async () => {
return await ListTool.execute(
{ path: "./example", ignore: [".git"] },
ctx,
)
return await ListTool.execute({ path: "./example", ignore: [".git"] }, ctx)
})
expect(result.output).toMatchSnapshot()
})
+3
View File
@@ -10,6 +10,7 @@ require (
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
github.com/charmbracelet/x/ansi v0.8.0
github.com/google/uuid v1.6.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0
@@ -20,6 +21,8 @@ require (
rsc.io/qr v0.2.0
)
replace github.com/sst/opencode-sdk-go => ./sdk
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
require (
+2 -2
View File
@@ -92,6 +92,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -181,8 +183,6 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+64 -38
View File
@@ -30,7 +30,7 @@ type App struct {
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.Message
Messages []opencode.MessageUnion
Commands commands.CommandRegistry
}
@@ -44,13 +44,10 @@ type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendMsg struct {
Text string
Attachments []Attachment
}
type CompletionDialogTriggeredMsg struct {
InitialValue string
Attachments []opencode.FilePartParam
}
type OptimisticMessageAddedMsg struct {
Message opencode.Message
Message opencode.MessageUnion
}
type FileRenderedMsg struct {
FilePath string
@@ -119,7 +116,7 @@ func New(
State: appState,
Client: httpClient,
Session: &opencode.Session{},
Messages: []opencode.Message{},
Messages: []opencode.MessageUnion{},
Commands: commands.LoadFromConfig(configInfo),
}
@@ -129,7 +126,11 @@ func New(
func (a *App) Key(commandName commands.CommandName) string {
t := theme.CurrentTheme()
base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
muted := styles.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Faint(true).Render
muted := styles.NewStyle().
Background(t.Background()).
Foreground(t.TextMuted()).
Faint(true).
Render
command := a.Commands[commandName]
kb := command.Keybindings[0]
key := kb.Key
@@ -201,7 +202,10 @@ func (a *App) InitializeProvider() tea.Cmd {
}
}
func getDefaultModel(response *opencode.ConfigProvidersResponse, provider opencode.Provider) *opencode.Model {
func getDefaultModel(
response *opencode.ConfigProvidersResponse,
provider opencode.Provider,
) *opencode.Model {
if match, ok := response.Default[provider.ID]; ok {
model := provider.Models[match]
return &model
@@ -213,20 +217,16 @@ func getDefaultModel(response *opencode.ConfigProvidersResponse, provider openco
return nil
}
type Attachment struct {
FilePath string
FileName string
MimeType string
Content []byte
}
func (a *App) IsBusy() bool {
if len(a.Messages) == 0 {
return false
}
lastMessage := a.Messages[len(a.Messages)-1]
return lastMessage.Metadata.Time.Completed == 0
if casted, ok := lastMessage.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0
}
return false
}
func (a *App) SaveState() {
@@ -292,29 +292,43 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
return session, nil
}
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
func (a *App) SendChatMessage(
ctx context.Context,
text string,
attachments []opencode.FilePartParam,
) (*App, tea.Cmd) {
var cmds []tea.Cmd
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
if err != nil {
return toast.NewErrorToast(err.Error())
return a, toast.NewErrorToast(err.Error())
}
a.Session = session
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
}
optimisticMessage := opencode.Message{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.MessageRoleUser,
Parts: []opencode.MessagePart{{
Type: opencode.MessagePartTypeText,
Text: text,
}},
Metadata: opencode.MessageMetadata{
SessionID: a.Session.ID,
Time: opencode.MessageMetadataTime{
Created: float64(time.Now().Unix()),
},
optimisticParts := []opencode.UserMessagePart{{
Type: opencode.UserMessagePartTypeText,
Text: text,
}}
if len(attachments) > 0 {
for _, attachment := range attachments {
optimisticParts = append(optimisticParts, opencode.UserMessagePart{
Type: opencode.UserMessagePartTypeFile,
Filename: attachment.Filename.Value,
Mime: attachment.Mime.Value,
URL: attachment.URL.Value,
})
}
}
optimisticMessage := opencode.UserMessage{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.UserMessageRoleUser,
Parts: optimisticParts,
SessionID: a.Session.ID,
Time: opencode.UserMessageTime{
Created: float64(time.Now().Unix()),
},
}
@@ -322,13 +336,25 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
cmds = append(cmds, func() tea.Msg {
parts := []opencode.UserMessagePartUnionParam{
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
},
}
if len(attachments) > 0 {
for _, attachment := range attachments {
parts = append(parts, opencode.FilePartParam{
Mime: attachment.Mime,
Type: attachment.Type,
URL: attachment.URL,
Filename: attachment.Filename,
})
}
}
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
Parts: opencode.F([]opencode.MessagePartUnionParam{
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
},
}),
Parts: opencode.F(parts),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
@@ -342,7 +368,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
// The actual response will come through SSE
// For now, just return success
return tea.Batch(cmds...)
return a, tea.Batch(cmds...)
}
func (a *App) Cancel(ctx context.Context, sessionID string) error {
@@ -16,12 +16,11 @@ import (
type filesAndFoldersContextGroup struct {
app *app.App
prefix string
gitFiles []dialog.CompletionItemI
}
func (cg *filesAndFoldersContextGroup) GetId() string {
return cg.prefix
return "files"
}
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
@@ -61,7 +60,9 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
return items
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
func (cg *filesAndFoldersContextGroup) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
items := make([]dialog.CompletionItemI, 0)
query = strings.TrimSpace(query)
@@ -75,6 +76,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C
)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
return items, err
}
if files == nil {
return items, nil
}
for _, file := range *files {
@@ -101,9 +106,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
cg := &filesAndFoldersContextGroup{
app: app,
prefix: "file",
app: app,
}
cg.gitFiles = cg.getGitFiles()
go func() {
cg.gitFiles = cg.getGitFiles()
}()
return cg
}
@@ -1,32 +0,0 @@
package completions
import (
"strings"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
)
type CompletionManager struct {
providers map[string]dialog.CompletionProvider
}
func NewCompletionManager(app *app.App) *CompletionManager {
return &CompletionManager{
providers: map[string]dialog.CompletionProvider{
"files": NewFileAndFolderContextGroup(app),
"commands": NewCommandCompletionProvider(app),
},
}
}
func (m *CompletionManager) DefaultProvider() dialog.CompletionProvider {
return m.providers["commands"]
}
func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider {
if strings.HasPrefix(input, "/") {
return m.providers["commands"]
}
return m.providers["files"]
}
+97 -21
View File
@@ -3,11 +3,14 @@ package chat
import (
"fmt"
"log/slog"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/google/uuid"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
@@ -37,7 +40,6 @@ type EditorComponent interface {
type editorComponent struct {
app *app.App
textarea textarea.Model
attachments []app.Attachment
spinner spinner.Model
interruptKeyInDebounce bool
}
@@ -66,17 +68,54 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg:
if msg.IsCommand {
switch msg.ProviderID {
case "commands":
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
return m, tea.Batch(cmds...)
} else {
existingValue := m.textarea.Value()
case "files":
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.CompletionValue + " ")
return m, nil
}
// Replace the current token (after last space)
// The range to replace is from the '@' up to the current cursor position.
// Replace the search term (e.g., "@search") with an empty string first.
cursorCol := m.textarea.CursorColumn()
m.textarea.ReplaceRange(atIndex, cursorCol, "")
// Now, insert the attachment at the position where the '@' was.
// The cursor is now at `atIndex` after the replacement.
filePath := msg.CompletionValue
extension := filepath.Ext(filePath)
mediaType := ""
switch extension {
case ".jpg":
mediaType = "image/jpeg"
case ".png", ".jpeg", ".gif", ".webp":
mediaType = "image/" + extension[1:]
case ".pdf":
mediaType = "application/pdf"
default:
mediaType = "text/plain"
}
attachment := &textarea.Attachment{
ID: uuid.NewString(),
Display: "@" + filePath,
URL: fmt.Sprintf("file://./%s", filePath),
Filename: filePath,
MediaType: mediaType,
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
default:
existingValue := m.textarea.Value()
lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 {
m.textarea.SetValue(msg.CompletionValue + " ")
@@ -128,7 +167,15 @@ func (m *editorComponent) Content(width int) string {
if m.app.IsBusy() {
keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
hint = muted(
"working",
) + m.spinner.View() + muted(
" ",
) + base(
keyText+" again",
) + muted(
" interrupt",
)
} else {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
}
@@ -190,19 +237,29 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
}
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
m.textarea.ReplaceRange(len(value)-1, len(value), "")
m.textarea.InsertString("\n")
return m, nil
}
var cmds []tea.Cmd
attachments := m.textarea.GetAttachments()
fileParts := make([]opencode.FilePartParam, 0)
for _, attachment := range attachments {
fileParts = append(fileParts, opencode.FilePartParam{
Type: opencode.F(opencode.FilePartTypeFile),
Mime: opencode.F(attachment.MediaType),
URL: opencode.F(attachment.URL),
Filename: opencode.F(attachment.Filename),
})
}
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
attachments := m.attachments
m.attachments = nil
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
return m, tea.Batch(cmds...)
}
@@ -212,18 +269,23 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
}
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
imageBytes, text, err := image.GetImageFromClipboard()
_, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, nil
}
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
// if len(imageBytes) != 0 {
// attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
// attachment := app.Attachment{
// FilePath: attachmentName,
// FileName: attachmentName,
// Content: imageBytes,
// MimeType: "image/png",
// }
// m.attachments = append(m.attachments, attachment)
// } else {
m.textarea.InsertString(text)
// }
return m, nil
}
@@ -254,12 +316,26 @@ func createTextArea(existing *textarea.Model) textarea.Model {
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Attachment = styles.NewStyle().
Foreground(t.Secondary()).
Background(bgColor).
Lipgloss()
ta.Styles.SelectedAttachment = styles.NewStyle().
Foreground(t.Text()).
Background(t.Secondary()).
Lipgloss()
ta.Styles.Cursor.Color = t.Primary()
ta.Prompt = " "
+163 -178
View File
@@ -17,7 +17,6 @@ import (
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/tidwall/gjson"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@@ -217,17 +216,35 @@ func renderContentBlock(
func renderText(
app *app.App,
message opencode.Message,
message opencode.MessageUnion,
text string,
author string,
showToolDetails bool,
highlight bool,
width int,
toolCalls ...opencode.ToolInvocationPart,
extra string,
toolCalls ...opencode.ToolPart,
) string {
t := theme.CurrentTheme()
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).
var ts time.Time
backgroundColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
}
messageStyle := styles.NewStyle().Background(backgroundColor)
content := messageStyle.Render(text)
switch casted := message.(type) {
case opencode.AssistantMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
content = util.ToMarkdown(text, width, backgroundColor)
case opencode.UserMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
messageStyle = messageStyle.Width(width - 6)
}
timestamp := ts.
Local().
Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
@@ -237,30 +254,12 @@ func renderText(
info := fmt.Sprintf("%s (%s)", author, timestamp)
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
backgroundColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
}
messageStyle := styles.NewStyle().Background(backgroundColor)
if message.Role == opencode.MessageRoleUser {
messageStyle = messageStyle.Width(width - 6)
}
content := messageStyle.Render(text)
if message.Role == opencode.MessageRoleAssistant {
content = util.ToMarkdown(text, width, backgroundColor)
}
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
content = content + "\n\n"
for _, toolCall := range toolCalls {
title := renderToolTitle(toolCall, message.Metadata, width)
metadata := opencode.MessageMetadataTool{}
if _, ok := message.Metadata.Tool[toolCall.ToolInvocation.ToolCallID]; ok {
metadata = message.Metadata.Tool[toolCall.ToolInvocation.ToolCallID]
}
title := renderToolTitle(toolCall, width)
style := styles.NewStyle()
if _, ok := metadata.ExtraFields["error"]; ok {
if toolCall.State.Status == opencode.ToolPartStateStatusError {
style = style.Foreground(t.Error())
}
title = style.Render(title)
@@ -269,10 +268,14 @@ func renderText(
}
}
content = strings.Join([]string{content, info}, "\n")
sections := []string{content, info}
if extra != "" {
sections = append(sections, "\n"+extra)
}
content = strings.Join(sections, "\n")
switch message.Role {
case opencode.MessageRoleUser:
switch message.(type) {
case opencode.UserMessage:
return renderContentBlock(
app,
content,
@@ -281,7 +284,7 @@ func renderText(
WithTextColor(t.Text()),
WithBorderColorRight(t.Secondary()),
)
case opencode.MessageRoleAssistant:
case opencode.AssistantMessage:
return renderContentBlock(
app,
content,
@@ -295,39 +298,32 @@ func renderText(
func renderToolDetails(
app *app.App,
toolCall opencode.ToolInvocationPart,
messageMetadata opencode.MessageMetadata,
toolCall opencode.ToolPart,
highlight bool,
width int,
) string {
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
if slices.Contains(ignoredTools, toolCall.Tool) {
return ""
}
toolCallID := toolCall.ToolInvocation.ToolCallID
metadata := opencode.MessageMetadataTool{}
if _, ok := messageMetadata.Tool[toolCallID]; ok {
metadata = messageMetadata.Tool[toolCallID]
}
var result *string
if toolCall.ToolInvocation.Result != "" {
result = &toolCall.ToolInvocation.Result
}
if toolCall.ToolInvocation.State == "partial-call" {
title := renderToolTitle(toolCall, messageMetadata, width)
if toolCall.State.Status == opencode.ToolPartStateStatusPending || toolCall.State.Status == opencode.ToolPartStateStatusRunning {
title := renderToolTitle(toolCall, width)
return renderContentBlock(app, title, highlight, width)
}
toolArgsMap := make(map[string]any)
if toolCall.ToolInvocation.Args != nil {
value := toolCall.ToolInvocation.Args
var result *string
if toolCall.State.Output != "" {
result = &toolCall.State.Output
}
toolInputMap := make(map[string]any)
if toolCall.State.Input != nil {
value := toolCall.State.Input
if m, ok := value.(map[string]any); ok {
toolArgsMap = m
keys := make([]string, 0, len(toolArgsMap))
for key := range toolArgsMap {
toolInputMap = m
keys := make([]string, 0, len(toolInputMap))
for key := range toolInputMap {
keys = append(keys, key)
}
slices.Sort(keys)
@@ -335,7 +331,6 @@ func renderToolDetails(
}
body := ""
finished := result != nil && *result != ""
t := theme.CurrentTheme()
backgroundColor := t.BackgroundPanel()
borderColor := t.BackgroundPanel()
@@ -344,137 +339,128 @@ func renderToolDetails(
borderColor = t.BorderActive()
}
switch toolCall.ToolInvocation.ToolName {
case "read":
preview := metadata.ExtraFields["preview"]
if preview != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
}
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
diffField := metadata.ExtraFields["diff"]
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(width-2),
)
body = strings.TrimSpace(formattedDiff)
style := styles.NewStyle().
Background(backgroundColor).
Foreground(t.TextMuted()).
Padding(1, 2).
Width(width - 4)
if highlight {
style = style.Foreground(t.Text()).Bold(true)
}
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
title := renderToolTitle(toolCall, messageMetadata, width)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(
app,
content,
highlight,
width,
WithPadding(0),
WithBorderColor(borderColor),
)
return content
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
metadata := toolCall.State.Metadata.(map[string]any)
switch toolCall.Tool {
case "read":
preview := metadata["preview"]
if preview != nil && toolInputMap["filePath"] != nil {
filename := toolInputMap["filePath"].(string)
body = preview.(string)
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
}
}
case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
if content, ok := toolArgsMap["content"].(string); ok {
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics
case "edit":
if filename, ok := toolInputMap["filePath"].(string); ok {
diffField := metadata["diff"]
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(width-2),
)
body = strings.TrimSpace(formattedDiff)
style := styles.NewStyle().
Background(backgroundColor).
Foreground(t.TextMuted()).
Padding(1, 2).
Width(width - 4)
if highlight {
style = style.Foreground(t.Text()).Bold(true)
}
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
title := renderToolTitle(toolCall, width)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(
app,
content,
highlight,
width,
WithPadding(0),
WithBorderColor(borderColor),
)
return content
}
}
}
case "bash":
stdout := metadata.ExtraFields["stdout"]
if stdout != nil {
command := toolArgsMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = util.ToMarkdown(body, width, backgroundColor)
}
case "webfetch":
if format, ok := toolArgsMap["format"].(string); ok && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" {
case "write":
if filename, ok := toolInputMap["filePath"].(string); ok {
if content, ok := toolInputMap["content"].(string); ok {
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics
}
}
}
case "bash":
stdout := metadata["stdout"]
if stdout != nil {
command := toolInputMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = util.ToMarkdown(body, width, backgroundColor)
}
}
case "todowrite":
todos := metadata.JSON.ExtraFields["todos"]
if !todos.IsNull() && finished {
strTodos := todos.Raw()
todos := gjson.Parse(strTodos)
for _, todo := range todos.Array() {
content := todo.Get("content").String()
switch todo.Get("status").String() {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] %s\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
case "webfetch":
if format, ok := toolInputMap["format"].(string); ok && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = util.ToMarkdown(body, width, backgroundColor)
}
}
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata.JSON.ExtraFields["summary"]
if !summary.IsNull() {
strValue := summary.Raw()
toolcalls := gjson.Parse(strValue).Array()
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.Value().(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall opencode.ToolInvocationPart
_ = json.Unmarshal(data, &toolCall)
if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata)
var toolMetadata opencode.MessageMetadataTool
_ = json.Unmarshal(data, &toolMetadata)
step := renderToolTitle(toolCall, messageMetadata, width)
case "todowrite":
todos := metadata["todos"]
if todos != nil {
for _, item := range todos.([]any) {
todo := item.(map[string]any)
content := todo["content"].(string)
switch todo["status"] {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] %s\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata["summary"]
if summary != nil {
toolcalls := summary.([]any)
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall opencode.ToolPart
_ = json.Unmarshal(data, &toolCall)
step := renderToolTitle(toolCall, width)
step = "∟ " + step
steps = append(steps, step)
}
}
body = strings.Join(steps, "\n")
}
body = strings.Join(steps, "\n")
default:
if result == nil {
empty := ""
result = &empty
}
body = *result
body = util.TruncateHeight(body, 10)
}
default:
if result == nil {
empty := ""
result = &empty
}
body = *result
body = util.TruncateHeight(body, 10)
}
error := ""
if err, ok := metadata.ExtraFields["error"].(bool); ok && err {
if message, ok := metadata.ExtraFields["message"].(string); ok {
error = message
}
if toolCall.State.Status == opencode.ToolPartStateStatusError {
error = toolCall.State.Error
}
if error != "" {
@@ -489,7 +475,7 @@ func renderToolDetails(
body = util.TruncateHeight(body, 10)
}
title := renderToolTitle(toolCall, messageMetadata, width)
title := renderToolTitle(toolCall, width)
content := title + "\n\n" + body
return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor))
}
@@ -510,20 +496,19 @@ func renderToolName(name string) string {
}
func renderToolTitle(
toolCall opencode.ToolInvocationPart,
messageMetadata opencode.MessageMetadata,
toolCall opencode.ToolPart,
width int,
) string {
// TODO: handle truncate to width
if toolCall.ToolInvocation.State == "partial-call" {
return renderToolAction(toolCall.ToolInvocation.ToolName)
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
return renderToolAction(toolCall.Tool)
}
toolArgs := ""
toolArgsMap := make(map[string]any)
if toolCall.ToolInvocation.Args != nil {
value := toolCall.ToolInvocation.Args
if toolCall.State.Input != nil {
value := toolCall.State.Input
if m, ok := value.(map[string]any); ok {
toolArgsMap = m
@@ -541,8 +526,8 @@ func renderToolTitle(
}
}
title := renderToolName(toolCall.ToolInvocation.ToolName)
switch toolCall.ToolInvocation.ToolName {
title := renderToolName(toolCall.Tool)
switch toolCall.Tool {
case "read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("%s %s", title, toolArgs)
@@ -560,7 +545,7 @@ func renderToolTitle(
case "todowrite", "todoread":
// title is just the tool name
default:
toolName := renderToolName(toolCall.ToolInvocation.ToolName)
toolName := renderToolName(toolCall.Tool)
title = fmt.Sprintf("%s %s", toolName, toolArgs)
}
return title
@@ -640,8 +625,8 @@ type Diagnostic struct {
}
// renderDiagnostics formats LSP diagnostics for display in the TUI
func renderDiagnostics(metadata opencode.MessageMetadataTool, filePath string) string {
if diagnosticsData, ok := metadata.ExtraFields["diagnostics"].(map[string]any); ok {
func renderDiagnostics(metadata map[string]any, filePath string) string {
if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
var errorDiagnostics []string
for _, diagInterface := range fileDiagnostics {
@@ -9,6 +9,7 @@ import (
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -67,11 +68,9 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.selectedPart = -1
return m, nil
case app.OptimisticMessageAddedMsg:
m.renderView(m.width)
if m.tail {
m.viewport.GotoBottom()
}
return m, nil
m.tail = true
m.rendering = true
return m, m.Reload()
case dialog.ThemeSelectedMsg:
m.cache.Clear()
m.rendering = true
@@ -100,7 +99,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID {
if msg.Properties.Info.SessionID == m.app.Session.ID {
m.renderView(m.width)
if m.tail {
m.viewport.GotoBottom()
@@ -125,18 +124,58 @@ func (m *messagesComponent) renderView(width int) {
m.partCount = 0
m.lineCount = 0
orphanedToolCalls := make([]opencode.ToolInvocationPart, 0)
orphanedToolCalls := make([]opencode.ToolPart, 0)
for _, message := range m.app.Messages {
var content string
var cached bool
switch message.Role {
case opencode.MessageRoleUser:
for _, part := range message.Parts {
switch casted := message.(type) {
case opencode.UserMessage:
userLoop:
for partIndex, part := range casted.Parts {
switch part := part.AsUnion().(type) {
case opencode.TextPart:
key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount)
remainingParts := casted.Parts[partIndex+1:]
fileParts := make([]opencode.FilePart, 0)
for _, part := range remainingParts {
switch part := part.AsUnion().(type) {
case opencode.FilePart:
fileParts = append(fileParts, part)
}
}
flexItems := []layout.FlexItem{}
if len(fileParts) > 0 {
fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1)
mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
for _, filePart := range fileParts {
mediaType := ""
switch filePart.Mime {
case "text/plain":
mediaType = "txt"
case "image/png", "image/jpeg", "image/gif", "image/webp":
mediaType = "img"
mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
case "application/pdf":
mediaType = "pdf"
mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
}
flexItems = append(flexItems, layout.FlexItem{
View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
})
}
}
bgColor := t.BackgroundPanel()
files := layout.Render(
layout.FlexOptions{
Background: &bgColor,
Width: width - 6,
Direction: layout.Column,
},
flexItems...,
)
key := m.cache.GenerateKey(casted.ID, part.Text, width, m.selectedPart == m.partCount, files)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
@@ -147,6 +186,7 @@ func (m *messagesComponent) renderView(width int) {
m.showToolDetails,
m.partCount == m.selectedPart,
width,
files,
)
m.cache.Set(key, content)
}
@@ -154,35 +194,41 @@ func (m *messagesComponent) renderView(width int) {
m = m.updateSelected(content, part.Text)
blocks = append(blocks, content)
}
// Only render the first text part
break userLoop
}
}
case opencode.MessageRoleAssistant:
case opencode.AssistantMessage:
hasTextPart := false
for partIndex, p := range message.Parts {
for partIndex, p := range casted.Parts {
switch part := p.AsUnion().(type) {
case opencode.TextPart:
hasTextPart = true
finished := message.Metadata.Time.Completed > 0
remainingParts := message.Parts[partIndex+1:]
toolCallParts := make([]opencode.ToolInvocationPart, 0)
finished := casted.Time.Completed > 0
remainingParts := casted.Parts[partIndex+1:]
toolCallParts := make([]opencode.ToolPart, 0)
// sometimes tool calls happen without an assistant message
// these should be included in this assistant message as well
if len(orphanedToolCalls) > 0 {
toolCallParts = append(toolCallParts, orphanedToolCalls...)
orphanedToolCalls = make([]opencode.ToolInvocationPart, 0)
orphanedToolCalls = make([]opencode.ToolPart, 0)
}
remaining := true
for _, part := range remainingParts {
if !remaining {
break
}
switch part := part.AsUnion().(type) {
case opencode.TextPart:
// we only want tool calls associated with the current text part.
// if we hit another text part, we're done.
break
case opencode.ToolInvocationPart:
remaining = false
case opencode.ToolPart:
toolCallParts = append(toolCallParts, part)
if part.ToolInvocation.State != "result" {
if part.State.Status != opencode.ToolPartStateStatusCompleted || part.State.Status != opencode.ToolPartStateStatusError {
// i don't think there's a case where a tool call isn't in result state
// and the message time is 0, but just in case
finished = false
@@ -191,17 +237,18 @@ func (m *messagesComponent) renderView(width int) {
}
if finished {
key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
key := m.cache.GenerateKey(casted.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
m.app,
message,
p.Text,
message.Metadata.Assistant.ModelID,
casted.ModelID,
m.showToolDetails,
m.partCount == m.selectedPart,
width,
"",
toolCallParts...,
)
m.cache.Set(key, content)
@@ -211,10 +258,11 @@ func (m *messagesComponent) renderView(width int) {
m.app,
message,
p.Text,
message.Metadata.Assistant.ModelID,
casted.ModelID,
m.showToolDetails,
m.partCount == m.selectedPart,
width,
"",
toolCallParts...,
)
}
@@ -222,7 +270,7 @@ func (m *messagesComponent) renderView(width int) {
m = m.updateSelected(content, p.Text)
blocks = append(blocks, content)
}
case opencode.ToolInvocationPart:
case opencode.ToolPart:
if !m.showToolDetails {
if !hasTextPart {
orphanedToolCalls = append(orphanedToolCalls, part)
@@ -230,9 +278,9 @@ func (m *messagesComponent) renderView(width int) {
continue
}
if part.ToolInvocation.State == "result" {
key := m.cache.GenerateKey(message.ID,
part.ToolInvocation.ToolCallID,
if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError {
key := m.cache.GenerateKey(casted.ID,
part.ID,
m.showToolDetails,
width,
m.partCount == m.selectedPart,
@@ -242,7 +290,6 @@ func (m *messagesComponent) renderView(width int) {
content = renderToolDetails(
m.app,
part,
message.Metadata,
m.partCount == m.selectedPart,
width,
)
@@ -253,7 +300,6 @@ func (m *messagesComponent) renderView(width int) {
content = renderToolDetails(
m.app,
part,
message.Metadata,
m.partCount == m.selectedPart,
width,
)
@@ -267,14 +313,16 @@ func (m *messagesComponent) renderView(width int) {
}
error := ""
switch err := message.Metadata.Error.AsUnion().(type) {
case nil:
case opencode.MessageMetadataErrorMessageOutputLengthError:
error = "Message output length exceeded"
case opencode.ProviderAuthError:
error = err.Data.Message
case opencode.UnknownError:
error = err.Data.Message
if assistant, ok := message.(opencode.AssistantMessage); ok {
switch err := assistant.Error.AsUnion().(type) {
case nil:
case opencode.AssistantMessageErrorMessageOutputLengthError:
error = "Message output length exceeded"
case opencode.ProviderAuthError:
error = err.Data.Message
case opencode.UnknownError:
error = err.Data.Message
}
}
if error != "" {
@@ -7,7 +7,6 @@ import (
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -65,7 +64,7 @@ type CompletionProvider interface {
type CompletionSelectedMsg struct {
SearchString string
CompletionValue string
IsCommand bool
ProviderID string
}
type CompletionDialogCompleteItemMsg struct {
@@ -79,7 +78,6 @@ type CompletionDialog interface {
tea.ViewModel
SetWidth(width int)
IsEmpty() bool
SetProvider(provider CompletionProvider)
}
type completionDialogComponent struct {
@@ -114,8 +112,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case []CompletionItemI:
c.list.SetItems(msg)
case app.CompletionDialogTriggeredMsg:
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) {
@@ -125,9 +121,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var query string
query = c.pseudoSearchTextArea.Value()
if query != "" {
query = query[1:]
}
if query != c.query {
c.query = query
@@ -187,8 +180,9 @@ func (c *completionDialogComponent) View() string {
for _, cmd := range completions {
title := cmd.DisplayValue()
if len(title) > maxWidth-4 {
maxWidth = len(title) + 4
width := lipgloss.Width(title)
if width > maxWidth-4 {
maxWidth = width + 4
}
}
@@ -214,28 +208,14 @@ func (c *completionDialogComponent) IsEmpty() bool {
return c.list.IsEmpty()
}
func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
if c.completionProvider.GetId() != provider.GetId() {
c.completionProvider = provider
c.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
c.list.SetItems([]CompletionItemI{})
}
}
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
// Check if this is a command completion
isCommand := c.completionProvider.GetId() == "commands"
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
IsCommand: isCommand,
ProviderID: c.completionProvider.GetId(),
}),
c.close(),
)
+10 -12
View File
@@ -27,7 +27,6 @@ type FindDialog interface {
SetWidth(width int)
SetHeight(height int)
IsEmpty() bool
SetProvider(provider CompletionProvider)
}
type findDialogComponent struct {
@@ -125,7 +124,7 @@ func (f *findDialogComponent) View() string {
f.list.SetMaxWidth(f.width - 4)
inputView := f.textInput.View()
inputView = styles.NewStyle().
Background(t.BackgroundPanel()).
Background(t.BackgroundElement()).
Height(1).
Width(f.width-4).
Padding(0, 0).
@@ -151,12 +150,6 @@ func (f *findDialogComponent) IsEmpty() bool {
return f.list.IsEmpty()
}
func (f *findDialogComponent) SetProvider(provider CompletionProvider) {
f.completionProvider = provider
f.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
f.list.SetItems([]CompletionItemI{})
}
func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd {
return tea.Sequence(
f.Close(),
@@ -178,15 +171,21 @@ func (f *findDialogComponent) Close() tea.Cmd {
func createTextInput(existing *textinput.Model) textinput.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundPanel()
bgColor := t.BackgroundElement()
textColor := t.Text()
textMutedColor := t.TextMuted()
ti := textinput.New()
ti.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ti.Styles.Blurred.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ti.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ti.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ti.Styles.Focused.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ti.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ti.Styles.Cursor.Color = t.Primary()
ti.VirtualCursor = true
@@ -213,7 +212,6 @@ func NewFindDialog(completionProvider CompletionProvider) FindDialog {
false,
)
// Load initial items
go func() {
items, err := completionProvider.GetChildEntries("")
if err != nil {
@@ -56,24 +56,24 @@ func (m ModelItem) Render(selected bool, width int) string {
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
return styles.NewStyle().
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Foreground(t.BackgroundPanel()).
Width(width).
PaddingLeft(1).
Render(displayText)
} else {
modelStyle := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundElement())
Background(t.BackgroundPanel())
providerStyle := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundElement())
Background(t.BackgroundPanel())
modelPart := modelStyle.Render(m.ModelName)
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
combinedText := modelPart + providerPart
return styles.NewStyle().
Background(t.BackgroundElement()).
Background(t.BackgroundPanel()).
PaddingLeft(1).
Render(combinedText)
}
+12 -2
View File
@@ -158,7 +158,12 @@ func (c *listComponent[T]) View() string {
return strings.Join(listItems, "\n")
}
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
func NewListComponent[T ListItem](
items []T,
maxVisibleItems int,
fallbackMsg string,
useAlphaNumericKeys bool,
) List[T] {
return &listComponent[T]{
fallbackMsg: fallbackMsg,
items: items,
@@ -194,7 +199,12 @@ func (s StringItem) Render(selected bool, width int) string {
}
// NewStringList creates a new list component with string items
func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] {
func NewStringList(
items []string,
maxVisibleItems int,
fallbackMsg string,
useAlphaNumericKeys bool,
) List[StringItem] {
stringItems := make([]StringItem, len(items))
for i, item := range items {
stringItems[i] = StringItem(item)
@@ -90,7 +90,7 @@ func (m *Modal) Render(contentView string, background string) string {
innerWidth := outerWidth - 4
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement())
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel())
var finalContent string
if m.title != "" {
@@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string {
modalView,
background,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(t.BorderActive()),
layout.WithOverlayBorderColor(t.Primary()),
)
}
+1 -1
View File
@@ -23,7 +23,7 @@ func Generate(text string) (string, int, error) {
}
// Create lipgloss style for QR code with theme colors
qrStyle := styles.NewStyleWithColors(t.Text(), t.Background())
qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
var result strings.Builder
@@ -6,6 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -101,18 +102,20 @@ func (m statusComponent) View() string {
contextWindow := m.app.Model.Limit.Context
for _, message := range m.app.Messages {
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
if usage.Output > 0 {
if message.Metadata.Assistant.Summary {
tokens = usage.Output
continue
if assistant, ok := message.(opencode.AssistantMessage); ok {
cost += assistant.Cost
usage := assistant.Tokens
if usage.Output > 0 {
if assistant.Summary {
tokens = usage.Output
continue
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,41 +0,0 @@
package layout_test
import (
"fmt"
"github.com/sst/opencode/internal/layout"
)
func ExampleRender_withGap() {
// Create a horizontal layout with 3px gap between items
result := layout.Render(
layout.FlexOptions{
Direction: layout.Row,
Width: 30,
Height: 1,
Gap: 3,
},
layout.FlexItem{View: "Item1"},
layout.FlexItem{View: "Item2"},
layout.FlexItem{View: "Item3"},
)
fmt.Println(result)
// Output: Item1 Item2 Item3
}
func ExampleRender_withGapAndJustify() {
// Create a horizontal layout with gap and space-between justification
result := layout.Render(
layout.FlexOptions{
Direction: layout.Row,
Width: 30,
Height: 1,
Gap: 2,
Justify: layout.JustifySpaceBetween,
},
layout.FlexItem{View: "A"},
layout.FlexItem{View: "B"},
layout.FlexItem{View: "C"},
)
fmt.Println(result)
// Output: A B C
}
-90
View File
@@ -1,90 +0,0 @@
package layout
import (
"strings"
"testing"
)
func TestFlexGap(t *testing.T) {
tests := []struct {
name string
opts FlexOptions
items []FlexItem
expected string
}{
{
name: "Row with gap",
opts: FlexOptions{
Direction: Row,
Width: 20,
Height: 1,
Gap: 2,
},
items: []FlexItem{
{View: "A"},
{View: "B"},
{View: "C"},
},
expected: "A B C",
},
{
name: "Column with gap",
opts: FlexOptions{
Direction: Column,
Width: 1,
Height: 5,
Gap: 1,
Align: AlignStart,
},
items: []FlexItem{
{View: "A", FixedSize: 1},
{View: "B", FixedSize: 1},
{View: "C", FixedSize: 1},
},
expected: "A\n \nB\n \nC",
},
{
name: "Row with gap and justify space between",
opts: FlexOptions{
Direction: Row,
Width: 15,
Height: 1,
Gap: 1,
Justify: JustifySpaceBetween,
},
items: []FlexItem{
{View: "A"},
{View: "B"},
{View: "C"},
},
expected: "A B C",
},
{
name: "No gap specified",
opts: FlexOptions{
Direction: Row,
Width: 10,
Height: 1,
},
items: []FlexItem{
{View: "A"},
{View: "B"},
{View: "C"},
},
expected: "ABC",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Render(tt.opts, tt.items...)
// Trim any trailing spaces for comparison
result = strings.TrimRight(result, " ")
expected := strings.TrimRight(tt.expected, " ")
if result != expected {
t.Errorf("Render() = %q, want %q", result, expected)
}
})
}
}
+70 -61
View File
@@ -51,8 +51,10 @@ type appModel struct {
editor chat.EditorComponent
messages chat.MessagesComponent
completions dialog.CompletionDialog
completionManager *completions.CompletionManager
commandProvider dialog.CompletionProvider
fileProvider dialog.CompletionProvider
showCompletionDialog bool
fileCompletionActive bool
leaderBinding *key.Binding
isLeaderSequence bool
toastManager *toast.ToastManager
@@ -105,6 +107,7 @@ var BUGGED_SCROLL_KEYS = map[string]bool{
"m": true,
"[": true,
";": true,
"<": true,
}
func isScrollRelatedInput(keyString string) bool {
@@ -134,6 +137,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
keyString := msg.String()
if time.Since(a.lastScroll) < time.Millisecond*100 && (BUGGED_SCROLL_KEYS[keyString] || isScrollRelatedInput(keyString)) {
return a, nil
}
@@ -174,37 +178,38 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// 3. Handle completions trigger
if keyString == "/" && !a.showCompletionDialog {
if keyString == "/" &&
!a.showCompletionDialog &&
a.editor.Value() == "" {
a.showCompletionDialog = true
a.fileCompletionActive = false
initialValue := "/"
currentInput := a.editor.Value()
// if the input doesn't end with a space,
// then we want to include the last word
// (ie, `packages/`)
if !strings.HasSuffix(currentInput, " ") {
words := strings.Split(a.editor.Value(), " ")
if len(words) > 0 {
lastWord := words[len(words)-1]
lastWord = strings.TrimSpace(lastWord)
initialValue = lastWord + "/"
}
}
updated, cmd := a.completions.Update(
app.CompletionDialogTriggeredMsg{
InitialValue: initialValue,
},
)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
updated, cmd = a.editor.Update(msg)
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
updated, cmd = a.updateCompletions(msg)
// Set command provider for command completion
a.completions = dialog.NewCompletionDialogComponent(a.commandProvider)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
return a, tea.Sequence(cmds...)
}
// Handle file completions trigger
if keyString == "@" &&
!a.showCompletionDialog {
a.showCompletionDialog = true
a.fileCompletionActive = true
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
// Set file provider for file completion
a.completions = dialog.NewCompletionDialogComponent(a.fileProvider)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
@@ -213,8 +218,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showCompletionDialog {
switch keyString {
case "tab", "enter", "esc", "ctrl+c":
updated, cmd := a.updateCompletions(msg)
case "tab", "enter", "esc", "ctrl+c", "up", "down":
updated, cmd := a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
@@ -224,7 +229,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
updated, cmd = a.updateCompletions(msg)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
@@ -345,10 +350,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, toast.NewErrorToast(msg.Error())
case app.SendMsg:
a.showCompletionDialog = false
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
a.fileCompletionActive = false
case opencode.EventListResponseEventInstallationUpdated:
return a, toast.NewSuccessToast(
"opencode updated to "+msg.Properties.Version+", restart to apply.",
@@ -357,7 +363,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case opencode.EventListResponseEventSessionDeleted:
if a.app.Session != nil && msg.Properties.Info.ID == a.app.Session.ID {
a.app.Session = &opencode.Session{}
a.app.Messages = []opencode.Message{}
a.app.Messages = []opencode.MessageUnion{}
}
return a, toast.NewSuccessToast("Session deleted successfully")
case opencode.EventListResponseEventSessionUpdated:
@@ -365,7 +371,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Session = &msg.Properties.Info
}
case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.ID {
if msg.Properties.Info.SessionID == a.app.Session.ID {
exists := false
optimisticReplaced := false
@@ -373,12 +379,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Properties.Info.Role == opencode.MessageRoleUser {
// Look for optimistic messages to replace
for i, m := range a.app.Messages {
if strings.HasPrefix(m.ID, "optimistic-") && m.Role == opencode.MessageRoleUser {
// Replace the optimistic message with the real one
a.app.Messages[i] = msg.Properties.Info
exists = true
optimisticReplaced = true
break
switch m := m.(type) {
case opencode.UserMessage:
if strings.HasPrefix(m.ID, "optimistic-") && m.Role == opencode.UserMessageRoleUser {
// Replace the optimistic message with the real one
a.app.Messages[i] = msg.Properties.Info.AsUnion()
exists = true
optimisticReplaced = true
break
}
}
}
}
@@ -386,8 +395,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// If not replacing optimistic, check for existing message with same ID
if !optimisticReplaced {
for i, m := range a.app.Messages {
if m.ID == msg.Properties.Info.ID {
a.app.Messages[i] = msg.Properties.Info
var id string
switch m := m.(type) {
case opencode.UserMessage:
id = m.ID
case opencode.AssistantMessage:
id = m.ID
}
if id == msg.Properties.Info.ID {
a.app.Messages[i] = msg.Properties.Info.AsUnion()
exists = true
break
}
@@ -395,7 +411,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if !exists {
a.app.Messages = append(a.app.Messages, msg.Properties.Info)
a.app.Messages = append(a.app.Messages, msg.Properties.Info.AsUnion())
}
}
case opencode.EventListResponseEventSessionError:
@@ -456,7 +472,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, toast.NewErrorToast("Failed to open session")
}
a.app.Session = msg
a.app.Messages = messages
a.app.Messages = make([]opencode.MessageUnion, 0)
for _, message := range messages {
a.app.Messages = append(a.app.Messages, message.AsUnion())
}
return a, util.CmdHandler(app.SessionLoadedMsg{})
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
@@ -797,11 +816,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return nil
}
os.Remove(tmpfile.Name())
// attachments := m.attachments
// m.attachments = nil
return app.SendMsg{
Text: string(content),
Attachments: []app.Attachment{}, // attachments,
Text: string(content),
}
})
cmds = append(cmds, cmd)
@@ -810,7 +826,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return a, nil
}
a.app.Session = &opencode.Session{}
a.app.Messages = []opencode.Message{}
a.app.Messages = []opencode.MessageUnion{}
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
case commands.SessionListCommand:
sessionDialog := dialog.NewSessionDialog(a.app)
@@ -971,22 +987,13 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return a, tea.Batch(cmds...)
}
func (a appModel) updateCompletions(msg tea.Msg) (tea.Model, tea.Cmd) {
currentInput := a.editor.Value()
if currentInput != "" {
provider := a.completionManager.GetProvider(currentInput)
a.completions.SetProvider(provider)
}
return a.completions.Update(msg)
}
func NewModel(app *app.App) tea.Model {
completionManager := completions.NewCompletionManager(app)
initialProvider := completionManager.DefaultProvider()
commandProvider := completions.NewCommandCompletionProvider(app)
fileProvider := completions.NewFileAndFolderContextGroup(app)
messages := chat.NewMessagesComponent(app)
editor := chat.NewEditorComponent(app)
completions := dialog.NewCompletionDialogComponent(initialProvider)
completions := dialog.NewCompletionDialogComponent(commandProvider)
var leaderBinding *key.Binding
if app.Config.Keybinds.Leader != "" {
@@ -1000,10 +1007,12 @@ func NewModel(app *app.App) tea.Model {
editor: editor,
messages: messages,
completions: completions,
completionManager: completionManager,
commandProvider: commandProvider,
fileProvider: fileProvider,
leaderBinding: leaderBinding,
isLeaderSequence: false,
showCompletionDialog: false,
fileCompletionActive: false,
toastManager: toast.NewToastManager(),
interruptKeyState: InterruptKeyIdle,
fileViewer: fileviewer.New(app),
+1 -1
View File
@@ -83,7 +83,7 @@ func Extension(path string) string {
}
func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
r := styles.GetMarkdownRenderer(width-7, backgroundColor)
r := styles.GetMarkdownRenderer(width-6, backgroundColor)
content = strings.ReplaceAll(content, RootPath+"/", "")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
@@ -0,0 +1,7 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/debian
{
"name": "Development",
"image": "mcr.microsoft.com/devcontainers/go:1.23-bookworm",
"postCreateCommand": "go mod tidy"
}
+49
View File
@@ -0,0 +1,49 @@
name: CI
on:
push:
branches-ignore:
- "generated"
- "codegen/**"
- "integrated/**"
- "stl-preview-head/**"
- "stl-preview-base/**"
pull_request:
branches-ignore:
- "stl-preview-head/**"
- "stl-preview-base/**"
jobs:
lint:
timeout-minutes: 10
name: lint
runs-on: ${{ github.repository == 'stainless-sdks/opencode-go' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@v4
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Run lints
run: ./scripts/lint
test:
timeout-minutes: 10
name: test
runs-on: ${{ github.repository == 'stainless-sdks/opencode-go' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@v4
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Bootstrap
run: ./scripts/bootstrap
- name: Run tests
run: ./scripts/test
+4
View File
@@ -0,0 +1,4 @@
.prism.log
codegen.log
Brewfile.lock.json
.idea/
@@ -0,0 +1,3 @@
{
".": "0.1.0-alpha.8"
}
+4
View File
@@ -0,0 +1,4 @@
configured_endpoints: 20
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-15eeb028f79b9a065b4e54a6ea6a58631e9bd5004f97820f0c79d18e3f8bac84.yml
openapi_spec_hash: 38c8bacb6c8e4c46852a3e81e3fb9fda
config_hash: e03e9d1aad76081fa1163086e89f201b
+1
View File
@@ -0,0 +1 @@
brew "go"
+72
View File
@@ -0,0 +1,72 @@
# Changelog
## 0.1.0-alpha.8 (2025-07-02)
Full Changelog: [v0.1.0-alpha.7...v0.1.0-alpha.8](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.7...v0.1.0-alpha.8)
### Features
- **api:** update via SDK Studio ([651e937](https://github.com/sst/opencode-sdk-go/commit/651e937c334e1caba3b968e6cac865c219879519))
## 0.1.0-alpha.7 (2025-06-30)
Full Changelog: [v0.1.0-alpha.6...v0.1.0-alpha.7](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.6...v0.1.0-alpha.7)
### Features
- **api:** update via SDK Studio ([13550a5](https://github.com/sst/opencode-sdk-go/commit/13550a5c65d77325e945ed99fe0799cd1107b775))
- **api:** update via SDK Studio ([7b73730](https://github.com/sst/opencode-sdk-go/commit/7b73730c7fa62ba966dda3541c3e97b49be8d2bf))
### Chores
- **ci:** only run for pushes and fork pull requests ([bea59b8](https://github.com/sst/opencode-sdk-go/commit/bea59b886800ef555f89c47a9256d6392ed2e53d))
## 0.1.0-alpha.6 (2025-06-28)
Full Changelog: [v0.1.0-alpha.5...v0.1.0-alpha.6](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.5...v0.1.0-alpha.6)
### Bug Fixes
- don't try to deserialize as json when ResponseBodyInto is []byte ([5988d04](https://github.com/sst/opencode-sdk-go/commit/5988d04839cb78b6613057280b91b72a60fef33d))
## 0.1.0-alpha.5 (2025-06-27)
Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.4...v0.1.0-alpha.5)
### Features
- **api:** update via SDK Studio ([9e39a59](https://github.com/sst/opencode-sdk-go/commit/9e39a59b3d5d1bd5e64633732521fb28362cc70e))
## 0.1.0-alpha.4 (2025-06-27)
Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.3...v0.1.0-alpha.4)
### Features
- **api:** update via SDK Studio ([9609d1b](https://github.com/sst/opencode-sdk-go/commit/9609d1b1db7806d00cb846c9914cb4935cdedf52))
## 0.1.0-alpha.3 (2025-06-27)
Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.2...v0.1.0-alpha.3)
### Features
- **api:** update via SDK Studio ([57f3230](https://github.com/sst/opencode-sdk-go/commit/57f32309023cc1f0f20c20d02a3907e390a71f61))
## 0.1.0-alpha.2 (2025-06-27)
Full Changelog: [v0.1.0-alpha.1...v0.1.0-alpha.2](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.1...v0.1.0-alpha.2)
### Features
- **api:** update via SDK Studio ([a766f1c](https://github.com/sst/opencode-sdk-go/commit/a766f1c54f02bbc1380151b0e22d97cc2c5892e6))
## 0.1.0-alpha.1 (2025-06-27)
Full Changelog: [v0.0.1-alpha.0...v0.1.0-alpha.1](https://github.com/sst/opencode-sdk-go/compare/v0.0.1-alpha.0...v0.1.0-alpha.1)
### Features
- **api:** update via SDK Studio ([27b7376](https://github.com/sst/opencode-sdk-go/commit/27b7376310466ee17a63f2104f546b53a2b8361a))
- **api:** update via SDK Studio ([0a73e04](https://github.com/sst/opencode-sdk-go/commit/0a73e04c23c90b2061611edaa8fd6282dc0ce397))
- **api:** update via SDK Studio ([9b7883a](https://github.com/sst/opencode-sdk-go/commit/9b7883a144eeac526d9d04538e0876a9d18bb844))
+66
View File
@@ -0,0 +1,66 @@
## Setting up the environment
To set up the repository, run:
```sh
$ ./scripts/bootstrap
$ ./scripts/build
```
This will install all the required dependencies and build the SDK.
You can also [install go 1.18+ manually](https://go.dev/doc/install).
## Modifying/Adding code
Most of the SDK is generated code. Modifications to code will be persisted between generations, but may
result in merge conflicts between manual patches and changes from the generator. The generator will never
modify the contents of the `lib/` and `examples/` directories.
## Adding and running examples
All files in the `examples/` directory are not modified by the generator and can be freely edited or added to.
```go
# add an example to examples/<your-example>/main.go
package main
func main() {
// ...
}
```
```sh
$ go run ./examples/<your-example>
```
## Using the repository from source
To use a local version of this library from source in another project, edit the `go.mod` with a replace
directive. This can be done through the CLI with the following:
```sh
$ go mod edit -replace github.com/sst/opencode-sdk-go=/path/to/opencode-sdk-go
```
## Running tests
Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests.
```sh
# you will need npm installed
$ npx prism mock path/to/your/openapi.yml
```
```sh
$ ./scripts/test
```
## Formatting
This library uses the standard gofmt code formatter:
```sh
$ ./scripts/format
```
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Opencode
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Some files were not shown because too many files have changed in this diff Show More