mirror of
https://github.com/sst/opencode.git
synced 2025-07-09 17:14:59 +00:00
Compare commits
98 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1f6efc6b94 | ||
![]() |
727fe6f942 | ||
![]() |
a91e79382e | ||
![]() |
5c626e0a2f | ||
![]() |
8e9e383219 | ||
![]() |
f383008cc1 | ||
![]() |
303ade25ed | ||
![]() |
53f8e7850e | ||
![]() |
ca8ce88354 | ||
![]() |
37a86439c4 | ||
![]() |
269b43f4de | ||
![]() |
3f25e5bf86 | ||
![]() |
67765fa47c | ||
![]() |
58b1c58bc5 | ||
![]() |
d80badc50f | ||
![]() |
75279e5ccf | ||
![]() |
7893b84614 | ||
![]() |
cfc715bd48 | ||
![]() |
39bcba85a9 | ||
![]() |
da3df51316 | ||
![]() |
12190e4efc | ||
![]() |
d2a9b2f64a | ||
![]() |
aacadd8a8a | ||
![]() |
969154a473 | ||
![]() |
4d6ca3fab1 | ||
![]() |
00ea5082e7 | ||
![]() |
4a878b88c0 | ||
![]() |
6de955847c | ||
![]() |
3ba5d528b4 | ||
![]() |
f99e2b3429 | ||
![]() |
7e4e6f6e51 | ||
![]() |
0514f3f43b | ||
![]() |
1e07384364 | ||
![]() |
4c4739c422 | ||
![]() |
2d8b90a6ff | ||
![]() |
a2fa7ffa42 | ||
![]() |
f7d6175283 | ||
![]() |
9ed187ee52 | ||
![]() |
14d81e574b | ||
![]() |
6efe8cc8df | ||
![]() |
daa5fc916a | ||
![]() |
c659496b96 | ||
![]() |
21fbf21cb6 | ||
![]() |
f31cbf2744 | ||
![]() |
8322f18e03 | ||
![]() |
562bdb95e2 | ||
![]() |
a57ce8365d | ||
![]() |
0da83ae67e | ||
![]() |
662d022a48 | ||
![]() |
9efef03919 | ||
![]() |
7a9fb3fa92 | ||
![]() |
ea96ead346 | ||
![]() |
6100a77b85 | ||
![]() |
c7a59ee2b1 | ||
![]() |
a272b58fe9 | ||
![]() |
9948fcf1b6 | ||
![]() |
0d50c867ff | ||
![]() |
27f7e02f12 | ||
![]() |
0f93ecd564 | ||
![]() |
da909d9684 | ||
![]() |
facd851b11 | ||
![]() |
c51de945a5 | ||
![]() |
9253a3ca9e | ||
![]() |
7cfa297a78 | ||
![]() |
661b74def6 | ||
![]() |
b478e5655c | ||
![]() |
f884766445 | ||
![]() |
76b2e4539c | ||
![]() |
d87922c0eb | ||
![]() |
2446483df5 | ||
![]() |
f4c453155d | ||
![]() |
969ad80ed2 | ||
![]() |
af064b41d7 | ||
![]() |
ea6bfef21a | ||
![]() |
107363b1d9 | ||
![]() |
85214d7c59 | ||
![]() |
997cb2d945 | ||
![]() |
45b139390c | ||
![]() |
994368de15 | ||
![]() |
143fd8e076 | ||
![]() |
06dba28bd6 | ||
![]() |
b8d276a049 | ||
![]() |
ee01f01271 | ||
![]() |
32d5db4f0a | ||
![]() |
f6108b7be8 | ||
![]() |
94ef341c9d | ||
![]() |
f9abc7c84f | ||
![]() |
891ed6ebc0 | ||
![]() |
163e23a68b | ||
![]() |
f13b0af491 | ||
![]() |
4a0be45d3d | ||
![]() |
23788674c8 | ||
![]() |
121eb24e73 | ||
![]() |
571d60182a | ||
![]() |
167a9dcaf3 | ||
![]() |
37327259cb | ||
![]() |
cdb25656d5 | ||
![]() |
25c876caa2 |
163 changed files with 9022 additions and 8567 deletions
2
.github/workflows/stats.yml
vendored
2
.github/workflows/stats.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
6
STATS.md
6
STATS.md
|
@ -1,9 +1,13 @@
|
|||
# Download Stats
|
||||
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | --------------- | --------------- |
|
||||
| ---------- | ---------------- | --------------- | ---------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 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) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
|
|
87
bun.lock
87
bun.lock
|
@ -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=="],
|
||||
|
|
|
@ -39,6 +39,8 @@ new sst.cloudflare.x.Astro("Web", {
|
|||
domain,
|
||||
path: "packages/web",
|
||||
environment: {
|
||||
// For astro config
|
||||
SST_STAGE: $app.stage,
|
||||
VITE_API_URL: api.url,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"weather": {
|
||||
"type": "local",
|
||||
"command": ["opencode", "x", "@h1deya/mcp-server-weather"]
|
||||
}
|
||||
},
|
||||
"experimental": {
|
||||
"hook": {
|
||||
"file_edited": {
|
||||
|
|
14
package.json
14
package.json
|
@ -7,7 +7,7 @@
|
|||
"scripts": {
|
||||
"dev": "bun run packages/opencode/src/index.ts",
|
||||
"typecheck": "bun run --filter='*' typecheck",
|
||||
"stainless": "bun run ./packages/opencode/src/index.ts serve ",
|
||||
"stainless": "./scripts/stainless",
|
||||
"postinstall": "./scripts/hooks"
|
||||
},
|
||||
"workspaces": {
|
||||
|
@ -17,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",
|
||||
|
@ -31,10 +31,8 @@
|
|||
},
|
||||
"license": "MIT",
|
||||
"prettier": {
|
||||
"semi": false
|
||||
},
|
||||
"overrides": {
|
||||
"zod": "3.24.2"
|
||||
"semi": false,
|
||||
"printWidth": 120
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
|
|
|
@ -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
packages/opencode/.gitignore
vendored
1
packages/opencode/.gitignore
vendored
|
@ -1,4 +1,3 @@
|
|||
node_modules
|
||||
research
|
||||
dist
|
||||
gen
|
||||
|
|
|
@ -1,369 +0,0 @@
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON schema reference for configuration validation"
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"description": "Theme name to use for the interface"
|
||||
},
|
||||
"keybinds": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leader": {
|
||||
"type": "string",
|
||||
"description": "Leader key for keybind combinations"
|
||||
},
|
||||
"help": {
|
||||
"type": "string",
|
||||
"description": "Show help dialog"
|
||||
},
|
||||
"editor_open": {
|
||||
"type": "string",
|
||||
"description": "Open external editor"
|
||||
},
|
||||
"session_new": {
|
||||
"type": "string",
|
||||
"description": "Create a new session"
|
||||
},
|
||||
"session_list": {
|
||||
"type": "string",
|
||||
"description": "List all sessions"
|
||||
},
|
||||
"session_share": {
|
||||
"type": "string",
|
||||
"description": "Share current session"
|
||||
},
|
||||
"session_interrupt": {
|
||||
"type": "string",
|
||||
"description": "Interrupt current session"
|
||||
},
|
||||
"session_compact": {
|
||||
"type": "string",
|
||||
"description": "Toggle compact mode for session"
|
||||
},
|
||||
"tool_details": {
|
||||
"type": "string",
|
||||
"description": "Show tool details"
|
||||
},
|
||||
"model_list": {
|
||||
"type": "string",
|
||||
"description": "List available models"
|
||||
},
|
||||
"theme_list": {
|
||||
"type": "string",
|
||||
"description": "List available themes"
|
||||
},
|
||||
"project_init": {
|
||||
"type": "string",
|
||||
"description": "Initialize project configuration"
|
||||
},
|
||||
"input_clear": {
|
||||
"type": "string",
|
||||
"description": "Clear input field"
|
||||
},
|
||||
"input_paste": {
|
||||
"type": "string",
|
||||
"description": "Paste from clipboard"
|
||||
},
|
||||
"input_submit": {
|
||||
"type": "string",
|
||||
"description": "Submit input"
|
||||
},
|
||||
"input_newline": {
|
||||
"type": "string",
|
||||
"description": "Insert newline in input"
|
||||
},
|
||||
"history_previous": {
|
||||
"type": "string",
|
||||
"description": "Navigate to previous history item"
|
||||
},
|
||||
"history_next": {
|
||||
"type": "string",
|
||||
"description": "Navigate to next history item"
|
||||
},
|
||||
"messages_page_up": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages up by one page"
|
||||
},
|
||||
"messages_page_down": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages down by one page"
|
||||
},
|
||||
"messages_half_page_up": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages up by half page"
|
||||
},
|
||||
"messages_half_page_down": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages down by half page"
|
||||
},
|
||||
"messages_previous": {
|
||||
"type": "string",
|
||||
"description": "Navigate to previous message"
|
||||
},
|
||||
"messages_next": {
|
||||
"type": "string",
|
||||
"description": "Navigate to next message"
|
||||
},
|
||||
"messages_first": {
|
||||
"type": "string",
|
||||
"description": "Navigate to first message"
|
||||
},
|
||||
"messages_last": {
|
||||
"type": "string",
|
||||
"description": "Navigate to last message"
|
||||
},
|
||||
"app_exit": {
|
||||
"type": "string",
|
||||
"description": "Exit the application"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Custom keybind configurations"
|
||||
},
|
||||
"autoshare": {
|
||||
"type": "boolean",
|
||||
"description": "Share newly created sessions automatically"
|
||||
},
|
||||
"autoupdate": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically update to the latest version"
|
||||
},
|
||||
"disabled_providers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Disable providers that are loaded automatically"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"env": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"npm": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"attachment": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_call": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"cost": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "number"
|
||||
},
|
||||
"output": {
|
||||
"type": "number"
|
||||
},
|
||||
"cache_read": {
|
||||
"type": "number"
|
||||
},
|
||||
"cache_write": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["input", "output"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"limit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"context": {
|
||||
"type": "number"
|
||||
},
|
||||
"output": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["context", "output"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"required": ["models"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Custom provider configurations and model overrides"
|
||||
},
|
||||
"mcp": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "local",
|
||||
"description": "Type of MCP server connection"
|
||||
},
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Command and arguments to run the MCP server"
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Environment variables to set when running the MCP server"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable the MCP server on startup"
|
||||
}
|
||||
},
|
||||
"required": ["type", "command"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "remote",
|
||||
"description": "Type of MCP server connection"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL of the remote MCP server"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable the MCP server on startup"
|
||||
}
|
||||
},
|
||||
"required": ["type", "url"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "MCP (Model Context Protocol) server configurations"
|
||||
},
|
||||
"instructions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Additional instruction files or patterns to include"
|
||||
},
|
||||
"experimental": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hook": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_edited": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"session_completed": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
|
@ -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 || [])
|
||||
|
||||
|
@ -109,6 +103,7 @@ if (!snapshot) {
|
|||
const lower = x.toLowerCase()
|
||||
return (
|
||||
!lower.includes("ignore:") &&
|
||||
!lower.includes("chore:") &&
|
||||
!lower.includes("ci:") &&
|
||||
!lower.includes("wip:") &&
|
||||
!lower.includes("docs:") &&
|
||||
|
@ -117,26 +112,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 +152,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}"`
|
||||
|
|
|
@ -4,5 +4,32 @@ import "zod-openapi/extend"
|
|||
import { Config } from "../src/config/config"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
|
||||
const result = zodToJsonSchema(Config.Info)
|
||||
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
|
||||
const file = process.argv[2]
|
||||
|
||||
const result = zodToJsonSchema(Config.Info, {
|
||||
/**
|
||||
* We'll use the `default` values of the field as the only value in `examples`.
|
||||
* This will ensure no docs are needed to be read, as the configuration is
|
||||
* self-documenting.
|
||||
*
|
||||
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
|
||||
*/
|
||||
postProcess(jsonSchema) {
|
||||
const schema = jsonSchema as typeof jsonSchema & {
|
||||
examples?: unknown[]
|
||||
}
|
||||
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
|
||||
if (!schema.examples) {
|
||||
schema.examples = [schema.default]
|
||||
}
|
||||
|
||||
schema.description = [schema.description || "", `default: \`${schema.default}\``]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
return jsonSchema
|
||||
},
|
||||
})
|
||||
await Bun.write(file, JSON.stringify(result, null, 2))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,9 +51,7 @@ 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",
|
||||
{
|
||||
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
@ -69,8 +61,7 @@ export namespace AuthAnthropic {
|
|||
refresh_token: info.refresh,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
},
|
||||
)
|
||||
})
|
||||
if (!response.ok) return
|
||||
const json = await response.json()
|
||||
await Auth.set("anthropic", {
|
||||
|
|
|
@ -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(() => {})
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,20 +1,15 @@
|
|||
import { App } from "../app/app"
|
||||
import { ConfigHooks } from "../config/hooks"
|
||||
import { FileWatcher } from "../file/watch"
|
||||
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()
|
||||
ConfigHooks.init()
|
||||
LSP.init()
|
||||
FileWatcher.init()
|
||||
|
||||
return cb(app)
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
})),
|
||||
})
|
||||
|
|
|
@ -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() {},
|
||||
})
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,11 +22,9 @@ 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)
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = await LSP.workspaceSymbol(args.query)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
|
|
|
@ -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() {},
|
||||
})
|
||||
|
||||
|
@ -50,7 +45,7 @@ const FilesCommand = cmd({
|
|||
const files = await Ripgrep.files({
|
||||
cwd: app.path.cwd,
|
||||
query: args.query,
|
||||
glob: args.glob,
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
limit: args.limit,
|
||||
})
|
||||
console.log(files.join("\n"))
|
||||
|
|
|
@ -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() {},
|
||||
})
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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],
|
||||
|
@ -54,7 +54,10 @@ export const RunCommand = cmd({
|
|||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
const message = args.message.join(" ")
|
||||
let message = args.message.join(" ")
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
const session = await (async () => {
|
||||
if (args.continue) {
|
||||
|
@ -78,27 +81,19 @@ export const RunCommand = cmd({
|
|||
UI.empty()
|
||||
UI.println(UI.logo())
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
const displayMessage = message.length > 300 ? message.slice(0, 300) + "..." : message
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", displayMessage)
|
||||
UI.empty()
|
||||
|
||||
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 +105,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") {
|
||||
|
@ -141,6 +125,20 @@ export const RunCommand = cmd({
|
|||
}
|
||||
})
|
||||
|
||||
let errorMsg: string | undefined
|
||||
Bus.subscribe(Session.Event.Error, async (evt) => {
|
||||
const { sessionID, error } = evt.properties
|
||||
if (sessionID !== session.id || !error) return
|
||||
let err = String(error.name)
|
||||
|
||||
if ("data" in error && error.data && "message" in error.data) {
|
||||
err = error.data.message
|
||||
}
|
||||
errorMsg = errorMsg ? errorMsg + "\n" + err : err
|
||||
|
||||
UI.error(err)
|
||||
})
|
||||
|
||||
const result = await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
|
@ -156,6 +154,7 @@ export const RunCommand = cmd({
|
|||
if (isPiped) {
|
||||
const match = result.parts.findLast((x) => x.type === "text")
|
||||
if (match) process.stdout.write(match.text)
|
||||
if (errorMsg) process.stdout.write(errorMsg)
|
||||
}
|
||||
UI.empty()
|
||||
})
|
||||
|
|
|
@ -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(() => {})
|
||||
|
||||
|
|
|
@ -10,14 +10,26 @@ import { Installation } from "../../installation"
|
|||
import { Config } from "../../config/config"
|
||||
import { Bus } from "../../bus"
|
||||
import { Log } from "../../util/log"
|
||||
import { FileWatcher } from "../../file/watch"
|
||||
|
||||
export const TuiCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
describe: "start opencode tui",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("project", {
|
||||
yargs
|
||||
.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start opencode in",
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("prompt", {
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
|
@ -29,6 +41,7 @@ export const TuiCommand = cmd({
|
|||
return
|
||||
}
|
||||
const result = await bootstrap({ cwd }, async (app) => {
|
||||
FileWatcher.init()
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
|
@ -40,9 +53,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
|
||||
|
@ -62,13 +73,18 @@ export const TuiCommand = cmd({
|
|||
cmd,
|
||||
})
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...cmd, ...process.argv.slice(2)],
|
||||
cmd: [
|
||||
...cmd,
|
||||
...(args.model ? ["--model", args.model] : []),
|
||||
...(args.prompt ? ["--prompt", args.prompt] : []),
|
||||
],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: "0",
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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,69 +57,43 @@ export namespace Config {
|
|||
|
||||
export const Keybinds = z
|
||||
.object({
|
||||
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"),
|
||||
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"),
|
||||
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"),
|
||||
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
|
||||
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
|
||||
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
||||
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
|
||||
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
||||
session_share: z.string().optional().default("<leader>s").describe("Share current session"),
|
||||
session_unshare: z.string().optional().default("<leader>u").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
|
||||
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
|
||||
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
|
||||
model_list: z.string().optional().default("<leader>m").describe("List available models"),
|
||||
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
|
||||
file_list: z.string().optional().default("<leader>f").describe("List files"),
|
||||
file_close: z.string().optional().default("esc").describe("Close file"),
|
||||
file_search: z.string().optional().default("<leader>/").describe("Search file"),
|
||||
file_diff_toggle: z.string().optional().default("<leader>v").describe("Split/unified diff"),
|
||||
project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
input_submit: z.string().optional().default("enter").describe("Submit input"),
|
||||
input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"),
|
||||
messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"),
|
||||
messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"),
|
||||
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
|
||||
messages_half_page_down: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("ctrl+alt+d")
|
||||
.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_last: z.string().optional().describe("Navigate to last message"),
|
||||
app_exit: z.string().optional().describe("Exit the application"),
|
||||
messages_previous: z.string().optional().default("ctrl+up").describe("Navigate to previous message"),
|
||||
messages_next: z.string().optional().default("ctrl+down").describe("Navigate to next message"),
|
||||
messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
|
||||
messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
|
||||
messages_layout_toggle: z.string().optional().default("<leader>p").describe("Toggle layout"),
|
||||
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
|
||||
messages_revert: z.string().optional().default("<leader>r").describe("Revert message"),
|
||||
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
|
||||
})
|
||||
.strict()
|
||||
.openapi({
|
||||
|
@ -136,33 +101,14 @@ 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(),
|
||||
log_level: Log.Level.optional().describe("Minimum log level to write to log files"),
|
||||
provider: z
|
||||
.record(
|
||||
ModelsDev.Provider.partial().extend({
|
||||
|
@ -172,14 +118,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 +167,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(() => {})
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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],
|
||||
{
|
||||
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({
|
||||
|
|
|
@ -11,6 +11,19 @@ import { Log } from "../util/log"
|
|||
export namespace File {
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
path: z.string(),
|
||||
added: z.number().int(),
|
||||
removed: z.number().int(),
|
||||
status: z.enum(["added", "deleted", "modified"]),
|
||||
})
|
||||
.openapi({
|
||||
ref: "File",
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Edited: Bus.event(
|
||||
"file.edited",
|
||||
|
@ -24,20 +37,16 @@ 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 = []
|
||||
const changedFiles: Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
const lines = diffOutput.trim().split("\n")
|
||||
for (const line of lines) {
|
||||
const [added, removed, filepath] = line.split("\t")
|
||||
changedFiles.push({
|
||||
file: filepath,
|
||||
path: filepath,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
|
@ -45,22 +54,16 @@ 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,
|
||||
path: filepath,
|
||||
added: lines,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
|
@ -72,17 +75,13 @@ 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")
|
||||
for (const filepath of deletedFiles) {
|
||||
changedFiles.push({
|
||||
file: filepath,
|
||||
path: filepath,
|
||||
added: 0,
|
||||
removed: 0, // Could get original line count but would require another git command
|
||||
status: "deleted",
|
||||
|
@ -92,7 +91,7 @@ export namespace File {
|
|||
|
||||
return changedFiles.map((x) => ({
|
||||
...x,
|
||||
file: path.relative(app.path.cwd, path.join(app.path.root, x.file)),
|
||||
path: path.relative(app.path.cwd, path.join(app.path.root, x.path)),
|
||||
}))
|
||||
}
|
||||
|
||||
|
@ -112,11 +111,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,
|
||||
})
|
||||
|
|
|
@ -34,7 +34,8 @@ export namespace Ripgrep {
|
|||
|
||||
export const Match = z.object({
|
||||
type: z.literal("match"),
|
||||
data: z.object({
|
||||
data: z
|
||||
.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
|
@ -52,7 +53,8 @@ export namespace Ripgrep {
|
|||
end: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.openapi({ ref: "Match" }),
|
||||
})
|
||||
|
||||
const End = z.object({
|
||||
|
@ -122,15 +124,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 +137,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 +161,11 @@ export namespace Ripgrep {
|
|||
})
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
const proc = Bun.spawn(
|
||||
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
|
||||
{
|
||||
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 +187,16 @@ export namespace Ripgrep {
|
|||
return filepath
|
||||
}
|
||||
|
||||
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}`)
|
||||
export async function files(input: { cwd: string; query?: string; glob?: string[]; limit?: number }) {
|
||||
const commands = [`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*'`]
|
||||
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
commands[0] += ` --glob='${g}'`
|
||||
}
|
||||
}
|
||||
|
||||
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 +303,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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -21,11 +21,9 @@ export namespace FileWatcher {
|
|||
"file.watcher",
|
||||
() => {
|
||||
const app = App.use()
|
||||
if (!app.info.git) return {}
|
||||
try {
|
||||
const watcher = fs.watch(
|
||||
app.info.path.cwd,
|
||||
{ recursive: true },
|
||||
(event, file) => {
|
||||
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
|
||||
|
@ -36,8 +34,7 @@ export namespace FileWatcher {
|
|||
event,
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
return { watcher }
|
||||
} catch {
|
||||
return {}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,24 @@ const cli = yargs(hideBin(process.argv))
|
|||
})
|
||||
.middleware(async () => {
|
||||
await Log.init({ print: process.argv.includes("--print-logs") })
|
||||
|
||||
try {
|
||||
const { Config } = await import("./config/config")
|
||||
const { App } = await import("./app/app")
|
||||
|
||||
App.provide({ cwd: process.cwd() }, async () => {
|
||||
const cfg = await Config.get()
|
||||
if (cfg.log_level) {
|
||||
Log.setLevel(cfg.log_level as Log.Level)
|
||||
} else {
|
||||
const defaultLevel = Installation.isDev() ? "DEBUG" : "INFO"
|
||||
Log.setLevel(defaultLevel)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
Log.Default.error("failed to load config", { error: e })
|
||||
}
|
||||
|
||||
Log.Default.info("opencode", {
|
||||
version: Installation.VERSION,
|
||||
args: process.argv.slice(2),
|
||||
|
@ -55,10 +73,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 +112,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
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
|
@ -38,45 +34,54 @@ export namespace LSPClient {
|
|||
),
|
||||
}
|
||||
|
||||
export async function create(serverID: string, server: LSPServer.Handle) {
|
||||
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
|
||||
const app = App.info()
|
||||
log.info("starting client", { id: serverID })
|
||||
const l = log.clone().tag("serverID", input.serverID)
|
||||
l.info("starting client")
|
||||
|
||||
const connection = createMessageConnection(
|
||||
new StreamMessageReader(server.process.stdout),
|
||||
new StreamMessageWriter(server.process.stdin),
|
||||
new StreamMessageReader(input.server.process.stdout),
|
||||
new StreamMessageWriter(input.server.process.stdin),
|
||||
)
|
||||
|
||||
const diagnostics = new Map<string, Diagnostic[]>()
|
||||
connection.onNotification("textDocument/publishDiagnostics", (params) => {
|
||||
const path = new URL(params.uri).pathname
|
||||
log.info("textDocument/publishDiagnostics", {
|
||||
l.info("textDocument/publishDiagnostics", {
|
||||
path,
|
||||
})
|
||||
const exists = diagnostics.has(path)
|
||||
diagnostics.set(path, params.diagnostics)
|
||||
if (!exists && serverID === "typescript") return
|
||||
Bus.publish(Event.Diagnostics, { path, serverID })
|
||||
if (!exists && input.serverID === "typescript") return
|
||||
Bus.publish(Event.Diagnostics, { path, serverID: input.serverID })
|
||||
})
|
||||
connection.onRequest("window/workDoneProgress/create", (params) => {
|
||||
l.info("window/workDoneProgress/create", params)
|
||||
return null
|
||||
})
|
||||
connection.onRequest("workspace/configuration", async () => {
|
||||
return [{}]
|
||||
})
|
||||
connection.listen()
|
||||
|
||||
log.info("sending initialize", { id: serverID })
|
||||
l.info("sending initialize")
|
||||
await withTimeout(
|
||||
connection.sendRequest("initialize", {
|
||||
processId: server.process.pid,
|
||||
rootUri: "file://" + input.root,
|
||||
processId: input.server.process.pid,
|
||||
workspaceFolders: [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: "file://" + app.path.cwd,
|
||||
uri: "file://" + input.root,
|
||||
},
|
||||
],
|
||||
initializationOptions: {
|
||||
...server.initialization,
|
||||
...input.server.initialization,
|
||||
},
|
||||
capabilities: {
|
||||
window: {
|
||||
workDoneProgress: true,
|
||||
},
|
||||
workspace: {
|
||||
configuration: true,
|
||||
},
|
||||
|
@ -92,28 +97,33 @@ export namespace LSPClient {
|
|||
},
|
||||
}),
|
||||
5_000,
|
||||
).catch(() => {
|
||||
throw new InitializeError({ serverID })
|
||||
).catch((err) => {
|
||||
l.error("initialize error", { error: err })
|
||||
throw new InitializeError(
|
||||
{ serverID: input.serverID },
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await connection.sendNotification("initialized", {})
|
||||
log.info("initialized")
|
||||
|
||||
const files: {
|
||||
[path: string]: number
|
||||
} = {}
|
||||
|
||||
const result = {
|
||||
root: input.root,
|
||||
get serverID() {
|
||||
return serverID
|
||||
return input.serverID
|
||||
},
|
||||
get connection() {
|
||||
return connection
|
||||
},
|
||||
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 +155,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()
|
||||
|
@ -171,14 +176,19 @@ export namespace LSPClient {
|
|||
})
|
||||
},
|
||||
async shutdown() {
|
||||
log.info("shutting down", { serverID })
|
||||
l.info("shutting down")
|
||||
connection.end()
|
||||
connection.dispose()
|
||||
server.process.kill("SIGTERM")
|
||||
log.info("shutdown", { serverID })
|
||||
input.server.process.kill()
|
||||
l.info("shutdown")
|
||||
},
|
||||
}
|
||||
|
||||
if (input.server.onInitialized) {
|
||||
await input.server.onInitialized(result)
|
||||
}
|
||||
l.info("initialized")
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ import { Log } from "../util/log"
|
|||
import { LSPClient } from "./client"
|
||||
import path from "path"
|
||||
import { LSPServer } from "./server"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { z } from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
@ -28,7 +28,7 @@ export namespace LSP {
|
|||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "LSP.Symbol",
|
||||
ref: "Symbol",
|
||||
})
|
||||
export type Symbol = z.infer<typeof Symbol>
|
||||
|
||||
|
@ -36,31 +36,39 @@ export namespace LSP {
|
|||
"lsp",
|
||||
async (app) => {
|
||||
log.info("initializing")
|
||||
const clients = new Map<string, LSPClient.Info>()
|
||||
const clients: LSPClient.Info[] = []
|
||||
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
for (const extension of server.extensions) {
|
||||
const [file] = await Ripgrep.files({
|
||||
cwd: app.path.cwd,
|
||||
glob: "*" + extension,
|
||||
const roots = await server.roots(app)
|
||||
|
||||
for (const root of roots) {
|
||||
if (!Filesystem.overlaps(app.path.cwd, root)) continue
|
||||
log.info("", {
|
||||
root,
|
||||
serverID: server.id,
|
||||
})
|
||||
if (!file) continue
|
||||
const handle = await server.spawn(App.info())
|
||||
const handle = await server.spawn(App.info(), root)
|
||||
if (!handle) break
|
||||
const client = await LSPClient.create(server.id, handle).catch(
|
||||
() => {},
|
||||
)
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch((err) => {
|
||||
handle.process.kill()
|
||||
log.error("", { error: err })
|
||||
})
|
||||
if (!client) break
|
||||
clients.set(server.id, client)
|
||||
break
|
||||
clients.push(client)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("initialized")
|
||||
return {
|
||||
clients,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
for (const client of state.clients.values()) {
|
||||
for (const client of state.clients) {
|
||||
await client.shutdown()
|
||||
}
|
||||
},
|
||||
|
@ -77,9 +85,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 +103,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: {
|
||||
|
@ -117,16 +119,17 @@ export namespace LSP {
|
|||
|
||||
export async function workspaceSymbol(query: string) {
|
||||
return run((client) =>
|
||||
client.connection.sendRequest("workspace/symbol", {
|
||||
client.connection
|
||||
.sendRequest("workspace/symbol", {
|
||||
query,
|
||||
}),
|
||||
})
|
||||
.then((result: any) => result.slice(0, 10))
|
||||
.catch(() => []),
|
||||
).then((result) => result.flat() as LSP.Symbol[])
|
||||
}
|
||||
|
||||
async function run<T>(
|
||||
input: (client: LSPClient.Info) => Promise<T>,
|
||||
): Promise<T[]> {
|
||||
const clients = await state().then((x) => [...x.clients.values()])
|
||||
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
|
||||
const clients = await state().then((x) => x.clients)
|
||||
const tasks = clients.map((x) => input(x))
|
||||
return Promise.all(tasks)
|
||||
}
|
||||
|
|
|
@ -94,4 +94,6 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
|||
".yml": "yaml",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".zig": "zig",
|
||||
".zon": "zig",
|
||||
} as const
|
||||
|
|
|
@ -6,6 +6,10 @@ import { Log } from "../util/log"
|
|||
import { BunProc } from "../bun"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import { unique } from "remeda"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import type { LSPClient } from "./client"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
|
@ -13,33 +17,44 @@ export namespace LSPServer {
|
|||
export interface Handle {
|
||||
process: ChildProcessWithoutNullStreams
|
||||
initialization?: Record<string, any>
|
||||
onInitialized?: (lsp: LSPClient.Info) => Promise<void>
|
||||
}
|
||||
|
||||
type RootsFunction = (app: App.Info) => Promise<string[]>
|
||||
|
||||
const SimpleRoots = (patterns: string[]): RootsFunction => {
|
||||
return async (app) => {
|
||||
const files = await Ripgrep.files({
|
||||
glob: patterns.map(p => `**/${p}`),
|
||||
cwd: app.path.root,
|
||||
})
|
||||
const dirs = files.map((file) => path.dirname(file))
|
||||
return unique(dirs).map((dir) => path.join(app.path.root, dir))
|
||||
}
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
id: string
|
||||
extensions: string[]
|
||||
spawn(app: App.Info): Promise<Handle | undefined>
|
||||
global?: boolean
|
||||
roots: (app: App.Info) => Promise<string[]>
|
||||
spawn(app: App.Info, root: string): Promise<Handle | undefined>
|
||||
}
|
||||
|
||||
export const Typescript: Info = {
|
||||
id: "typescript",
|
||||
roots: async (app) => [app.path.root],
|
||||
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(() => {})
|
||||
async spawn(app, root) {
|
||||
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"],
|
||||
{
|
||||
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
return {
|
||||
process: proc,
|
||||
initialization: {
|
||||
|
@ -47,14 +62,32 @@ export namespace LSPServer {
|
|||
path: tsserver,
|
||||
},
|
||||
},
|
||||
// tsserver sucks and won't start processing codebase until you open a file
|
||||
onInitialized: async (lsp) => {
|
||||
const [hint] = await Ripgrep.files({
|
||||
cwd: lsp.root,
|
||||
glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
|
||||
limit: 1,
|
||||
})
|
||||
const wait = new Promise<void>(async (resolve) => {
|
||||
const notif = lsp.connection.onNotification("$/progress", (params) => {
|
||||
if (params.value.kind !== "end") return
|
||||
notif.dispose()
|
||||
resolve()
|
||||
})
|
||||
await lsp.notify.open({ path: path.join(lsp.root, hint) })
|
||||
})
|
||||
await withTimeout(wait, 5_000)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const Gopls: Info = {
|
||||
id: "golang",
|
||||
roots: SimpleRoots(["go.mod", "go.sum"]),
|
||||
extensions: [".go"],
|
||||
async spawn() {
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("gopls", {
|
||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||
})
|
||||
|
@ -73,24 +106,24 @@ 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,
|
||||
})
|
||||
}
|
||||
return {
|
||||
process: spawn(bin!),
|
||||
process: spawn(bin!, {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const RubyLsp: Info = {
|
||||
id: "ruby-lsp",
|
||||
roots: SimpleRoots(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn() {
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("ruby-lsp", {
|
||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||
})
|
||||
|
@ -113,16 +146,15 @@ 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,
|
||||
})
|
||||
}
|
||||
return {
|
||||
process: spawn(bin!, ["--stdio"]),
|
||||
process: spawn(bin!, ["--stdio"], {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -130,17 +162,22 @@ export namespace LSPServer {
|
|||
export const Pyright: Info = {
|
||||
id: "pyright",
|
||||
extensions: [".py", ".pyi"],
|
||||
async spawn() {
|
||||
const proc = spawn(
|
||||
BunProc.which(),
|
||||
["x", "pyright-langserver", "--stdio"],
|
||||
{
|
||||
roots: SimpleRoots([
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"requirements.txt",
|
||||
"Pipfile",
|
||||
"pyrightconfig.json",
|
||||
]),
|
||||
async spawn(_, root) {
|
||||
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
return {
|
||||
process: proc,
|
||||
}
|
||||
|
@ -150,7 +187,8 @@ export namespace LSPServer {
|
|||
export const ElixirLS: Info = {
|
||||
id: "elixir-ls",
|
||||
extensions: [".ex", ".exs"],
|
||||
async spawn() {
|
||||
roots: SimpleRoots(["mix.exs", "mix.lock"]),
|
||||
async spawn(_, root) {
|
||||
let binary = Bun.which("elixir-ls")
|
||||
if (!binary) {
|
||||
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
|
||||
|
@ -158,9 +196,7 @@ export namespace LSPServer {
|
|||
Global.Path.bin,
|
||||
"elixir-ls-master",
|
||||
"release",
|
||||
process.platform === "win32"
|
||||
? "language_server.bar"
|
||||
: "language_server.sh",
|
||||
process.platform === "win32" ? "language_server.bar" : "language_server.sh",
|
||||
)
|
||||
|
||||
if (!(await Bun.file(binary).exists())) {
|
||||
|
@ -172,9 +208,7 @@ export namespace LSPServer {
|
|||
|
||||
log.info("downloading elixir-ls from GitHub releases")
|
||||
|
||||
const response = await fetch(
|
||||
"https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip",
|
||||
)
|
||||
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)
|
||||
|
@ -198,7 +232,114 @@ export namespace LSPServer {
|
|||
}
|
||||
|
||||
return {
|
||||
process: spawn(binary),
|
||||
process: spawn(binary, {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const Zls: Info = {
|
||||
id: "zls",
|
||||
extensions: [".zig", ".zon"],
|
||||
roots: SimpleRoots(["build.zig"]),
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("zls", {
|
||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||
})
|
||||
|
||||
if (!bin) {
|
||||
const zig = Bun.which("zig")
|
||||
if (!zig) {
|
||||
log.error("Zig is required to use zls. Please install Zig first.")
|
||||
return
|
||||
}
|
||||
|
||||
log.info("downloading zls from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
|
||||
if (!releaseResponse.ok) {
|
||||
log.error("Failed to fetch zls release info")
|
||||
return
|
||||
}
|
||||
|
||||
const release = await releaseResponse.json()
|
||||
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
let assetName = ""
|
||||
|
||||
let zlsArch: string = arch
|
||||
if (arch === "arm64") zlsArch = "aarch64"
|
||||
else if (arch === "x64") zlsArch = "x86_64"
|
||||
else if (arch === "ia32") zlsArch = "x86"
|
||||
|
||||
let zlsPlatform: string = platform
|
||||
if (platform === "darwin") zlsPlatform = "macos"
|
||||
else if (platform === "win32") zlsPlatform = "windows"
|
||||
|
||||
const ext = platform === "win32" ? "zip" : "tar.xz"
|
||||
|
||||
assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}`
|
||||
|
||||
const supportedCombos = [
|
||||
"zls-x86_64-linux.tar.xz",
|
||||
"zls-x86_64-macos.tar.xz",
|
||||
"zls-x86_64-windows.zip",
|
||||
"zls-aarch64-linux.tar.xz",
|
||||
"zls-aarch64-macos.tar.xz",
|
||||
"zls-aarch64-windows.zip",
|
||||
"zls-x86-linux.tar.xz",
|
||||
"zls-x86-windows.zip",
|
||||
]
|
||||
|
||||
if (!supportedCombos.includes(assetName)) {
|
||||
log.error(`Platform ${platform} and architecture ${arch} is not supported by zls`)
|
||||
return
|
||||
}
|
||||
|
||||
const asset = release.assets.find((a: any) => a.name === assetName)
|
||||
if (!asset) {
|
||||
log.error(`Could not find asset ${assetName} in latest zls release`)
|
||||
return
|
||||
}
|
||||
|
||||
const downloadUrl = asset.browser_download_url
|
||||
const downloadResponse = await fetch(downloadUrl)
|
||||
if (!downloadResponse.ok) {
|
||||
log.error("Failed to download zls")
|
||||
return
|
||||
}
|
||||
|
||||
const tempPath = path.join(Global.Path.bin, assetName)
|
||||
await Bun.file(tempPath).write(downloadResponse)
|
||||
|
||||
if (ext === "zip") {
|
||||
await $`unzip -o -q ${tempPath}`.cwd(Global.Path.bin).nothrow()
|
||||
} else {
|
||||
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).nothrow()
|
||||
}
|
||||
|
||||
await fs.rm(tempPath, { force: true })
|
||||
|
||||
bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : ""))
|
||||
|
||||
if (!(await Bun.file(bin).exists())) {
|
||||
log.error("Failed to extract zls binary")
|
||||
return
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.nothrow()
|
||||
}
|
||||
|
||||
log.info(`installed zls`, { bin })
|
||||
}
|
||||
|
||||
return {
|
||||
process: spawn(bin, {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
@ -100,25 +99,27 @@ export namespace Provider {
|
|||
info.access = tokens.access
|
||||
}
|
||||
let isAgentCall = false
|
||||
let isVisionRequest = 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(
|
||||
isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role))
|
||||
isVisionRequest = body.messages.some(
|
||||
(msg: any) =>
|
||||
msg.role && ["tool", "assistant"].includes(msg.role),
|
||||
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
const headers = {
|
||||
const headers: Record<string, string> = {
|
||||
...init.headers,
|
||||
...copilot.HEADERS,
|
||||
Authorization: `Bearer ${info.access}`,
|
||||
"Openai-Intent": "conversation-edits",
|
||||
"X-Initiator": isAgentCall ? "agent" : "user",
|
||||
}
|
||||
if (isVisionRequest) {
|
||||
headers["Copilot-Vision-Request"] = "true"
|
||||
}
|
||||
delete headers["x-api-key"]
|
||||
return fetch(input, {
|
||||
...init,
|
||||
|
@ -138,14 +139,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 +155,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 +170,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 +219,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 +294,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 +321,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 +358,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 +385,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 +412,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 +423,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 +506,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[],
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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" },
|
||||
},
|
||||
|
|
|
@ -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(),
|
||||
{
|
||||
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),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
@ -566,7 +558,7 @@ export namespace Server {
|
|||
description: "Symbols",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.unknown().array()),
|
||||
schema: resolver(LSP.Symbol.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -629,16 +621,7 @@ export namespace Server {
|
|||
description: "File status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
file: z.string(),
|
||||
added: z.number().int(),
|
||||
removed: z.number().int(),
|
||||
status: z.enum(["added", "deleted", "modified"]),
|
||||
})
|
||||
.array(),
|
||||
),
|
||||
schema: resolver(File.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -649,6 +632,55 @@ export namespace Server {
|
|||
return c.json(content)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/log",
|
||||
describeRoute({
|
||||
description: "Write a log entry to the server logs",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Log entry written successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
service: z.string().openapi({ description: "Service name for the log entry" }),
|
||||
level: z.enum(["debug", "info", "error", "warn"]).openapi({ description: "Log level" }),
|
||||
message: z.string().openapi({ description: "Log message" }),
|
||||
extra: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.openapi({ description: "Additional metadata for the log entry" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { service, level, message, extra } = c.req.valid("json")
|
||||
const logger = Log.create({ service })
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
logger.debug(message, extra)
|
||||
break
|
||||
case "info":
|
||||
logger.info(message, extra)
|
||||
break
|
||||
case "error":
|
||||
logger.error(message, extra)
|
||||
break
|
||||
case "warn":
|
||||
logger.warn(message, extra)
|
||||
break
|
||||
}
|
||||
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
404
packages/opencode/src/session/message-v2.ts
Normal file
404
packages/opencode/src/session/message-v2.ts
Normal file
|
@ -0,0 +1,404 @@
|
|||
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 AbortedError = NamedError.create("MessageAbortedError", 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,
|
||||
AbortedError.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)
|
||||
}
|
||||
}
|
|
@ -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,9 +45,7 @@ export namespace Message {
|
|||
})
|
||||
export type ToolResult = z.infer<typeof ToolResult>
|
||||
|
||||
export const ToolInvocation = z
|
||||
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
|
||||
.openapi({
|
||||
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).openapi({
|
||||
ref: "ToolInvocation",
|
||||
})
|
||||
export type ToolInvocation = z.infer<typeof ToolInvocation>
|
||||
|
@ -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(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
95
packages/opencode/src/session/prompt/beast.txt
Normal file
95
packages/opencode/src/session/prompt/beast.txt
Normal file
|
@ -0,0 +1,95 @@
|
|||
You are an agent known as opencode - please keep going until the user’s query is completely resolved, before ending your turn and yielding back to the user.
|
||||
|
||||
Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.
|
||||
|
||||
You MUST iterate and keep going until the problem is solved.
|
||||
|
||||
I want you to fully solve this autonomously before coming back to me.
|
||||
|
||||
Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
|
||||
|
||||
Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.
|
||||
|
||||
If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.
|
||||
|
||||
Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
|
||||
|
||||
You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
|
||||
|
||||
# Workflow
|
||||
|
||||
1. Understand the problem deeply. Carefully read the issue and think critically about what is required.
|
||||
2. Investigate the codebase. Explore relevant files, search for key functions, and gather context.
|
||||
3. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. Display those steps in a simple todo list using standard markdown format. Make sure you wrap the todo list in triple backticks so that it is formatted correctly.
|
||||
4. Implement the fix incrementally. Make small, testable code changes.
|
||||
5. Debug as needed. Use debugging techniques to isolate and resolve issues.
|
||||
6. Test frequently. Run tests after each change to verify correctness.
|
||||
7. Iterate until the root cause is fixed and all tests pass.
|
||||
8. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete.
|
||||
|
||||
Refer to the detailed sections below for more information on each step.
|
||||
|
||||
## 1. Deeply Understand the Problem
|
||||
Carefully read the issue and think hard about a plan to solve it before coding.
|
||||
|
||||
## 2. Codebase Investigation
|
||||
- Explore relevant files and directories.
|
||||
- Search for key functions, classes, or variables related to the issue.
|
||||
- Read and understand relevant code snippets.
|
||||
- Identify the root cause of the problem.
|
||||
- Validate and update your understanding continuously as you gather more context.
|
||||
|
||||
## 3. Fetch Provided URLs
|
||||
- If the user provides a URL, use the `functions.fetch_webpage` tool to retrieve the content of the provided URL.
|
||||
- After fetching, review the content returned by the fetch tool.
|
||||
- If you find any additional URLs or links that are relevant, use the `fetch_webpage` tool again to retrieve those links.
|
||||
- Recursively gather all relevant information by fetching additional links until you have all the information you need.
|
||||
|
||||
## 4. Develop a Detailed Plan
|
||||
- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
|
||||
- Create a todo list in markdown format to track your progress.
|
||||
- Each time you complete a step, check it off using `[x]` syntax.
|
||||
- Each time you check off a step, display the updated todo list to the user.
|
||||
- Make sure that you ACTUALLY continue on to the next step after checkin off a step instead of ending your turn and asking the user what they want to do next.
|
||||
|
||||
## 5. Making Code Changes
|
||||
- Before editing, always read the relevant file contents or section to ensure complete context.
|
||||
- Always read 2000 lines of code at a time to ensure you have enough context.
|
||||
- If a patch is not applied correctly, attempt to reapply it.
|
||||
- Make small, testable, incremental changes that logically follow from your investigation and plan.
|
||||
|
||||
## 6. Debugging
|
||||
- Make code changes only if you have high confidence they can solve the problem
|
||||
- When debugging, try to determine the root cause rather than addressing symptoms
|
||||
- Debug for as long as needed to identify the root cause and identify a fix
|
||||
- Use the #problems tool to check for any problems in the code
|
||||
- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening
|
||||
- To test hypotheses, you can also add test statements or functions
|
||||
- Revisit your assumptions if unexpected behavior occurs.
|
||||
|
||||
# Fetch Webpage
|
||||
Use the `webfetch` tool when the user provides a URL. Follow these steps exactly.
|
||||
|
||||
1. Use the `webfetch` tool to retrieve the content of the provided URL.
|
||||
2. After fetching, review the content returned by the fetch tool.
|
||||
3. If you find any additional URLs or links that are relevant, use the `webfetch` tool again to retrieve those links.
|
||||
4. Go back to step 2 and repeat until you have all the information you need.
|
||||
|
||||
IMPORTANT: Recursively fetching links is crucial. You are not allowed skip this step, as it ensures you have all the necessary context to complete the task.
|
||||
|
||||
# How to create a Todo List
|
||||
Use the following format to create a todo list:
|
||||
```markdown
|
||||
- [ ] Step 1: Description of the first step
|
||||
- [ ] Step 2: Description of the second step
|
||||
- [ ] Step 3: Description of the third step
|
||||
```
|
||||
|
||||
Do not ever use HTML tags or any other formatting for the todo list, as it will not be rendered correctly. Always use the markdown format shown above.
|
||||
|
||||
# Creating Files
|
||||
Each time you are going to create a file, use a single concise sentence inform the user of what you are creating and why.
|
||||
|
||||
# Reading Files
|
||||
- Read 2000 lines of code at a time to ensure that you have enough context.
|
||||
- Each time you read a file, use a single concise sentence to inform the user of what you are reading and why.
|
|
@ -7,23 +7,16 @@ import path from "path"
|
|||
import os from "os"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_BEAST from "./prompt/beast.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
import PROMPT_SUMMARIZE from "./prompt/summarize.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
|
||||
export namespace SystemPrompt {
|
||||
export function provider(providerID: string) {
|
||||
const result = []
|
||||
switch (providerID) {
|
||||
case "anthropic":
|
||||
result.push(PROMPT_ANTHROPIC_SPOOF.trim())
|
||||
result.push(PROMPT_ANTHROPIC)
|
||||
break
|
||||
default:
|
||||
result.push(PROMPT_ANTHROPIC)
|
||||
break
|
||||
}
|
||||
return result
|
||||
export function provider(providerID: string, modelID: string) {
|
||||
if (providerID === "anthropic") return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_ANTHROPIC]
|
||||
if (modelID.includes("gpt-")) return [PROMPT_BEAST]
|
||||
return [PROMPT_ANTHROPIC]
|
||||
}
|
||||
|
||||
export async function environment() {
|
||||
|
|
|
@ -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`, {
|
||||
|
|
|
@ -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)
|
||||
|
||||
// not a git repo, check if too big to snapshot
|
||||
if (!app.git) {
|
||||
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,
|
||||
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",
|
||||
},
|
||||
|
||||
if (await fs.mkdir(git, { recursive: true })) {
|
||||
await $`git init`
|
||||
.env({
|
||||
...process.env,
|
||||
GIT_DIR: git,
|
||||
GIT_WORK_TREE: app.path.root,
|
||||
})
|
||||
log.info("commit", { result })
|
||||
return result
|
||||
.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) {
|
||||
|
|
|
@ -4,44 +4,80 @@ 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) => {
|
||||
try {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
]
|
||||
|
||||
const state = App.state("storage", async () => {
|
||||
const app = App.info()
|
||||
const dir = path.join(app.path.data, "storage")
|
||||
log.info("init", { path: dir })
|
||||
const dir = path.normalize(path.join(app.path.data, "storage"))
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
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 +85,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))
|
||||
|
|
|
@ -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"),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
@ -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")
|
||||
|
|
|
@ -20,16 +20,14 @@ 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 = []
|
||||
let truncated = false
|
||||
for (const file of await Ripgrep.files({
|
||||
cwd: search,
|
||||
glob: params.pattern,
|
||||
glob: [params.pattern],
|
||||
})) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true
|
||||
|
@ -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 +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"),
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ export const IGNORE_PATTERNS = [
|
|||
"obj/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
".zig-cache/",
|
||||
"zig-out",
|
||||
]
|
||||
|
||||
const LIMIT = 100
|
||||
|
@ -24,16 +26,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 +38,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 +92,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",
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
@ -2,7 +2,7 @@ Reads a file from the local filesystem. You can access any file directly by usin
|
|||
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- The filePath parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to 2000 lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any lines longer than 2000 characters will be truncated
|
||||
|
|
|
@ -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,
|
||||
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 {
|
||||
metadata: {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
summary: summary(result),
|
||||
},
|
||||
output: result.parts.findLast((x) => x.type === "text")!.text,
|
||||
|
|
|
@ -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", "cancelled"]).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),
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
return {
|
||||
output: content,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
case "markdown":
|
||||
|
@ -96,32 +85,28 @@ export const WebFetchTool = Tool.define({
|
|||
const markdown = convertHTMLToMarkdown(content)
|
||||
return {
|
||||
output: markdown,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
return {
|
||||
output: "```\n" + content + "\n```",
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
case "html":
|
||||
return {
|
||||
output: content,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
output: content,
|
||||
metadata: {
|
||||
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
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
import { exists } from "fs/promises"
|
||||
import { dirname, join } from "path"
|
||||
import { dirname, join, relative } from "path"
|
||||
|
||||
export namespace Filesystem {
|
||||
export function overlaps(a: string, b: string) {
|
||||
const relA = relative(a, b)
|
||||
const relB = relative(b, a)
|
||||
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
||||
}
|
||||
|
||||
export function contains(parent: string, child: string) {
|
||||
return relative(parent, child).startsWith("..")
|
||||
}
|
||||
|
||||
export async function findUp(target: string, start: string, stop?: string) {
|
||||
let current = start
|
||||
const result = []
|
||||
|
|
|
@ -1,15 +1,59 @@
|
|||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Global } from "../global"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Log {
|
||||
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).openapi({ ref: "LogLevel", description: "Log level" })
|
||||
export type Level = z.infer<typeof Level>
|
||||
|
||||
const levelPriority: Record<Level, number> = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
WARN: 2,
|
||||
ERROR: 3,
|
||||
}
|
||||
|
||||
let currentLevel: Level = "INFO"
|
||||
|
||||
export function setLevel(level: Level) {
|
||||
currentLevel = level
|
||||
}
|
||||
|
||||
export function getLevel(): Level {
|
||||
return currentLevel
|
||||
}
|
||||
|
||||
function shouldLog(level: Level): boolean {
|
||||
return levelPriority[level] >= levelPriority[currentLevel]
|
||||
}
|
||||
|
||||
export type Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>): void
|
||||
info(message?: any, extra?: Record<string, any>): void
|
||||
error(message?: any, extra?: Record<string, any>): void
|
||||
warn(message?: any, extra?: Record<string, any>): void
|
||||
tag(key: string, value: string): Logger
|
||||
clone(): Logger
|
||||
time(
|
||||
message: string,
|
||||
extra?: Record<string, any>,
|
||||
): {
|
||||
stop(): void
|
||||
[Symbol.dispose](): void
|
||||
}
|
||||
}
|
||||
|
||||
const loggers = new Map<string, Logger>()
|
||||
|
||||
export const Default = create({ service: "default" })
|
||||
|
||||
export interface Options {
|
||||
print: boolean
|
||||
level?: Level
|
||||
}
|
||||
|
||||
let logpath = ""
|
||||
|
||||
export function file() {
|
||||
return logpath
|
||||
}
|
||||
|
@ -19,10 +63,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,15 +84,21 @@ 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()
|
||||
export function create(tags?: Record<string, any>) {
|
||||
tags = tags || {}
|
||||
|
||||
const service = tags["service"]
|
||||
if (service && typeof service === "string") {
|
||||
const cached = loggers.get(service)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
function build(message: any, extra?: Record<string, any>) {
|
||||
const prefix = Object.entries({
|
||||
...tags,
|
||||
|
@ -63,21 +110,28 @@ 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 = {
|
||||
const result: Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("DEBUG")) {
|
||||
process.stderr.write("DEBUG " + build(message, extra))
|
||||
}
|
||||
},
|
||||
info(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("INFO")) {
|
||||
process.stderr.write("INFO " + build(message, extra))
|
||||
}
|
||||
},
|
||||
error(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("ERROR")) {
|
||||
process.stderr.write("ERROR " + build(message, extra))
|
||||
}
|
||||
},
|
||||
warn(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("WARN")) {
|
||||
process.stderr.write("WARN " + build(message, extra))
|
||||
}
|
||||
},
|
||||
tag(key: string, value: string) {
|
||||
if (tags) tags[key] = value
|
||||
|
@ -105,6 +159,10 @@ export namespace Log {
|
|||
},
|
||||
}
|
||||
|
||||
if (service && typeof service === "string") {
|
||||
loggers.set(service, result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
|
|
|
@ -5,14 +5,16 @@ import (
|
|||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
flag "github.com/spf13/pflag"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode-sdk-go/option"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
@ -23,6 +25,10 @@ func main() {
|
|||
version = "v" + Version
|
||||
}
|
||||
|
||||
var model *string = flag.String("model", "", "model to begin with")
|
||||
var prompt *string = flag.String("prompt", "", "prompt to begin with")
|
||||
flag.Parse()
|
||||
|
||||
url := os.Getenv("OPENCODE_SERVER")
|
||||
|
||||
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
|
||||
|
@ -33,39 +39,28 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
|
||||
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(filepath.Dir(logfile), 0755)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log directory", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
file, err := os.Create(logfile)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log file", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer file.Close()
|
||||
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Debug("TUI launched", "app", appInfo)
|
||||
|
||||
httpClient := opencode.NewClient(
|
||||
option.WithBaseURL(url),
|
||||
)
|
||||
|
||||
apiHandler := util.NewAPILogHandler(httpClient, "tui", slog.LevelDebug)
|
||||
logger := slog.New(apiHandler)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Debug("TUI launched", "app", appInfo)
|
||||
|
||||
go func() {
|
||||
err = clipboard.Init()
|
||||
if err != nil {
|
||||
slog.Error("Failed to create client", "error", err)
|
||||
os.Exit(1)
|
||||
slog.Error("Failed to initialize clipboard", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create main context for the application
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app_, err := app.New(ctx, version, appInfo, httpClient)
|
||||
app_, err := app.New(ctx, version, appInfo, httpClient, model, prompt)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
|
|
@ -10,13 +10,14 @@ 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
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
|
||||
github.com/tidwall/gjson v1.14.4
|
||||
golang.org/x/image v0.28.0
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
|
@ -49,23 +50,23 @@ require (
|
|||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
||||
github.com/spf13/cobra v1.9.1 // indirect
|
||||
github.com/tidwall/gjson v1.14.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
|
@ -77,16 +78,15 @@ require (
|
|||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/image v0.26.0
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/term v0.31.0 // indirect
|
||||
golang.org/x/text v0.24.0
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/text v0.26.0
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
|
|
|
@ -54,8 +54,6 @@ github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
|
@ -92,6 +90,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=
|
||||
|
@ -216,14 +216,13 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
|||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
|
@ -234,15 +233,15 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT
|
|||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
@ -261,28 +260,28 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
|
@ -30,8 +31,11 @@ type App struct {
|
|||
Provider *opencode.Provider
|
||||
Model *opencode.Model
|
||||
Session *opencode.Session
|
||||
Messages []opencode.Message
|
||||
Messages []opencode.MessageUnion
|
||||
Commands commands.CommandRegistry
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
compactCancel context.CancelFunc
|
||||
}
|
||||
|
||||
type SessionSelectedMsg = *opencode.Session
|
||||
|
@ -44,10 +48,10 @@ type SessionClearedMsg struct{}
|
|||
type CompactSessionMsg struct{}
|
||||
type SendMsg struct {
|
||||
Text string
|
||||
Attachments []Attachment
|
||||
Attachments []opencode.FilePartParam
|
||||
}
|
||||
type OptimisticMessageAddedMsg struct {
|
||||
Message opencode.Message
|
||||
Message opencode.MessageUnion
|
||||
}
|
||||
type FileRenderedMsg struct {
|
||||
FilePath string
|
||||
|
@ -58,6 +62,8 @@ func New(
|
|||
version string,
|
||||
appInfo opencode.App,
|
||||
httpClient *opencode.Client,
|
||||
model *string,
|
||||
prompt *string,
|
||||
) (*App, error) {
|
||||
util.RootPath = appInfo.Path.Root
|
||||
util.CwdPath = appInfo.Path.Cwd
|
||||
|
@ -116,8 +122,10 @@ func New(
|
|||
State: appState,
|
||||
Client: httpClient,
|
||||
Session: &opencode.Session{},
|
||||
Messages: []opencode.Message{},
|
||||
Messages: []opencode.MessageUnion{},
|
||||
Commands: commands.LoadFromConfig(configInfo),
|
||||
InitialModel: model,
|
||||
InitialPrompt: prompt,
|
||||
}
|
||||
|
||||
return app, nil
|
||||
|
@ -140,8 +148,18 @@ func (a *App) Key(commandName commands.CommandName) string {
|
|||
return base(key) + muted(" "+command.Description)
|
||||
}
|
||||
|
||||
func (a *App) SetClipboard(text string) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
clipboard.Write(clipboard.FmtText, []byte(text))
|
||||
return nil
|
||||
})
|
||||
// try to set the clipboard using OSC52 for terminals that support it
|
||||
cmds = append(cmds, tea.SetClipboard(text))
|
||||
return tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) InitializeProvider() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
providersResponse, err := a.Client.Config.Providers(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Failed to list providers", "error", err)
|
||||
|
@ -195,11 +213,37 @@ func (a *App) InitializeProvider() tea.Cmd {
|
|||
currentModel = defaultModel
|
||||
}
|
||||
|
||||
return ModelSelectedMsg{
|
||||
var initialProvider *opencode.Provider
|
||||
var initialModel *opencode.Model
|
||||
if a.InitialModel != nil && *a.InitialModel != "" {
|
||||
splits := strings.Split(*a.InitialModel, "/")
|
||||
for _, provider := range providers {
|
||||
if provider.ID == splits[0] {
|
||||
initialProvider = &provider
|
||||
for _, model := range provider.Models {
|
||||
modelID := strings.Join(splits[1:], "/")
|
||||
if model.ID == modelID {
|
||||
initialModel = &model
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if initialProvider != nil && initialModel != nil {
|
||||
currentProvider = initialProvider
|
||||
currentModel = initialModel
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
|
||||
Provider: *currentProvider,
|
||||
Model: *currentModel,
|
||||
}))
|
||||
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
|
||||
cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
|
||||
}
|
||||
}
|
||||
return tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
func getDefaultModel(
|
||||
|
@ -217,20 +261,16 @@ func getDefaultModel(
|
|||
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() {
|
||||
|
@ -267,14 +307,27 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
|||
}
|
||||
|
||||
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
||||
if a.compactCancel != nil {
|
||||
a.compactCancel()
|
||||
}
|
||||
|
||||
compactCtx, cancel := context.WithCancel(ctx)
|
||||
a.compactCancel = cancel
|
||||
|
||||
go func() {
|
||||
_, err := a.Client.Session.Summarize(ctx, a.Session.ID, opencode.SessionSummarizeParams{
|
||||
defer func() {
|
||||
a.compactCancel = nil
|
||||
}()
|
||||
|
||||
_, err := a.Client.Session.Summarize(compactCtx, a.Session.ID, opencode.SessionSummarizeParams{
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
})
|
||||
if err != nil {
|
||||
if compactCtx.Err() != context.Canceled {
|
||||
slog.Error("Failed to compact session", "error", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
@ -296,43 +349,69 @@ 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,
|
||||
optimisticParts := []opencode.UserMessagePart{{
|
||||
Type: opencode.UserMessagePartTypeText,
|
||||
Text: text,
|
||||
}},
|
||||
Metadata: opencode.MessageMetadata{
|
||||
}}
|
||||
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.MessageMetadataTime{
|
||||
Time: opencode.UserMessageTime{
|
||||
Created: float64(time.Now().Unix()),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
a.Messages = append(a.Messages, optimisticMessage)
|
||||
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
|
||||
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
||||
Parts: opencode.F([]opencode.MessagePartUnionParam{
|
||||
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(parts),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
})
|
||||
|
@ -346,10 +425,16 @@ 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 {
|
||||
// Cancel any running compact operation
|
||||
if a.compactCancel != nil {
|
||||
a.compactCancel()
|
||||
a.compactCancel = nil
|
||||
}
|
||||
|
||||
_, err := a.Client.Session.Abort(ctx, sessionID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to cancel session", "error", err)
|
||||
|
|
155
packages/tui/internal/clipboard/clipboard.go
Normal file
155
packages/tui/internal/clipboard/clipboard.go
Normal file
|
@ -0,0 +1,155 @@
|
|||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
/*
|
||||
Package clipboard provides cross platform clipboard access and supports
|
||||
macOS/Linux/Windows/Android/iOS platform. Before interacting with the
|
||||
clipboard, one must call Init to assert if it is possible to use this
|
||||
package:
|
||||
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
The most common operations are `Read` and `Write`. To use them:
|
||||
|
||||
// write/read text format data of the clipboard, and
|
||||
// the byte buffer regarding the text are UTF8 encoded.
|
||||
clipboard.Write(clipboard.FmtText, []byte("text data"))
|
||||
clipboard.Read(clipboard.FmtText)
|
||||
|
||||
// write/read image format data of the clipboard, and
|
||||
// the byte buffer regarding the image are PNG encoded.
|
||||
clipboard.Write(clipboard.FmtImage, []byte("image data"))
|
||||
clipboard.Read(clipboard.FmtImage)
|
||||
|
||||
Note that read/write regarding image format assumes that the bytes are
|
||||
PNG encoded since it serves the alpha blending purpose that might be
|
||||
used in other graphical software.
|
||||
|
||||
In addition, `clipboard.Write` returns a channel that can receive an
|
||||
empty struct as a signal, which indicates the corresponding write call
|
||||
to the clipboard is outdated, meaning the clipboard has been overwritten
|
||||
by others and the previously written data is lost. For instance:
|
||||
|
||||
changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
|
||||
|
||||
select {
|
||||
case <-changed:
|
||||
println(`"text data" is no longer available from clipboard.`)
|
||||
}
|
||||
|
||||
You can ignore the returning channel if you don't need this type of
|
||||
notification. Furthermore, when you need more than just knowing whether
|
||||
clipboard data is changed, use the watcher API:
|
||||
|
||||
ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
|
||||
for data := range ch {
|
||||
// print out clipboard data whenever it is changed
|
||||
println(string(data))
|
||||
}
|
||||
*/
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// activate only for running tests.
|
||||
debug = false
|
||||
errUnavailable = errors.New("clipboard unavailable")
|
||||
errUnsupported = errors.New("unsupported format")
|
||||
errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0")
|
||||
)
|
||||
|
||||
// Format represents the format of clipboard data.
|
||||
type Format int
|
||||
|
||||
// All sorts of supported clipboard data
|
||||
const (
|
||||
// FmtText indicates plain text clipboard format
|
||||
FmtText Format = iota
|
||||
// FmtImage indicates image/png clipboard format
|
||||
FmtImage
|
||||
)
|
||||
|
||||
var (
|
||||
// Due to the limitation on operating systems (such as darwin),
|
||||
// concurrent read can even cause panic, use a global lock to
|
||||
// guarantee one read at a time.
|
||||
lock = sync.Mutex{}
|
||||
initOnce sync.Once
|
||||
initError error
|
||||
)
|
||||
|
||||
// Init initializes the clipboard package. It returns an error
|
||||
// if the clipboard is not available to use. This may happen if the
|
||||
// target system lacks required dependency, such as libx11-dev in X11
|
||||
// environment. For example,
|
||||
//
|
||||
// err := clipboard.Init()
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
//
|
||||
// If Init returns an error, any subsequent Read/Write/Watch call
|
||||
// may result in an unrecoverable panic.
|
||||
func Init() error {
|
||||
initOnce.Do(func() {
|
||||
initError = initialize()
|
||||
})
|
||||
return initError
|
||||
}
|
||||
|
||||
// Read returns a chunk of bytes of the clipboard data if it presents
|
||||
// in the desired format t presents. Otherwise, it returns nil.
|
||||
func Read(t Format) []byte {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
buf, err := read(t)
|
||||
if err != nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
// Write writes a given buffer to the clipboard in a specified format.
|
||||
// Write returned a receive-only channel can receive an empty struct
|
||||
// as a signal, which indicates the clipboard has been overwritten from
|
||||
// this write.
|
||||
// If format t indicates an image, then the given buf assumes
|
||||
// the image data is PNG encoded.
|
||||
func Write(t Format, buf []byte) <-chan struct{} {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
changed, err := write(t, buf)
|
||||
if err != nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// Watch returns a receive-only channel that received the clipboard data
|
||||
// whenever any change of clipboard data in the desired format happens.
|
||||
//
|
||||
// The returned channel will be closed if the given context is canceled.
|
||||
func Watch(ctx context.Context, t Format) <-chan []byte {
|
||||
return watch(ctx, t)
|
||||
}
|
266
packages/tui/internal/clipboard/clipboard_darwin.go
Normal file
266
packages/tui/internal/clipboard/clipboard_darwin.go
Normal file
|
@ -0,0 +1,266 @@
|
|||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
lastChangeCount int64
|
||||
changeCountMu sync.Mutex
|
||||
)
|
||||
|
||||
func initialize() error { return nil }
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
switch t {
|
||||
case FmtText:
|
||||
return readText()
|
||||
case FmtImage:
|
||||
return readImage()
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func readText() ([]byte, error) {
|
||||
// Check if clipboard contains string data
|
||||
checkScript := `
|
||||
try
|
||||
set clipboardTypes to (clipboard info)
|
||||
repeat with aType in clipboardTypes
|
||||
if (first item of aType) is string then
|
||||
return "hastext"
|
||||
end if
|
||||
end repeat
|
||||
return "notext"
|
||||
on error
|
||||
return "error"
|
||||
end try
|
||||
`
|
||||
|
||||
cmd := exec.Command("osascript", "-e", checkScript)
|
||||
checkOut, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
checkOut = bytes.TrimSpace(checkOut)
|
||||
if !bytes.Equal(checkOut, []byte("hastext")) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Now get the actual text
|
||||
cmd = exec.Command("osascript", "-e", "get the clipboard")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
// Remove trailing newline that osascript adds
|
||||
out = bytes.TrimSuffix(out, []byte("\n"))
|
||||
|
||||
// If clipboard was set to empty string, return nil
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func readImage() ([]byte, error) {
|
||||
// AppleScript to read image data from clipboard as base64
|
||||
script := `
|
||||
try
|
||||
set theData to the clipboard as «class PNGf»
|
||||
return theData
|
||||
on error
|
||||
return ""
|
||||
end try
|
||||
`
|
||||
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Check if we got any data
|
||||
out = bytes.TrimSpace(out)
|
||||
if len(out) == 0 {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// The output is in hex format (e.g., «data PNGf89504E...»)
|
||||
// We need to extract and convert it
|
||||
outStr := string(out)
|
||||
if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Extract hex data
|
||||
hexData := strings.TrimPrefix(outStr, "«data PNGf")
|
||||
hexData = strings.TrimSuffix(hexData, "»")
|
||||
|
||||
// Convert hex to bytes
|
||||
buf := make([]byte, len(hexData)/2)
|
||||
for i := 0; i < len(hexData); i += 2 {
|
||||
b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
buf[i/2] = byte(b)
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// write writes the given data to clipboard and
|
||||
// returns true if success or false if failed.
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
var err error
|
||||
switch t {
|
||||
case FmtText:
|
||||
err = writeText(buf)
|
||||
case FmtImage:
|
||||
err = writeImage(buf)
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update change count
|
||||
changeCountMu.Lock()
|
||||
lastChangeCount++
|
||||
currentCount := lastChangeCount
|
||||
changeCountMu.Unlock()
|
||||
|
||||
// use unbuffered channel to prevent goroutine leak
|
||||
changed := make(chan struct{}, 1)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
changeCountMu.Lock()
|
||||
if lastChangeCount != currentCount {
|
||||
changeCountMu.Unlock()
|
||||
changed <- struct{}{}
|
||||
close(changed)
|
||||
return
|
||||
}
|
||||
changeCountMu.Unlock()
|
||||
}
|
||||
}()
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func writeText(buf []byte) error {
|
||||
if len(buf) == 0 {
|
||||
// Clear clipboard
|
||||
script := `set the clipboard to ""`
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Escape the text for AppleScript
|
||||
text := string(buf)
|
||||
text = strings.ReplaceAll(text, "\\", "\\\\")
|
||||
text = strings.ReplaceAll(text, "\"", "\\\"")
|
||||
|
||||
script := fmt.Sprintf(`set the clipboard to "%s"`, text)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func writeImage(buf []byte) error {
|
||||
if len(buf) == 0 {
|
||||
// Clear clipboard
|
||||
script := `set the clipboard to ""`
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a temporary file to store the PNG data
|
||||
tmpFile, err := os.CreateTemp("", "clipboard*.png")
|
||||
if err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.Write(buf); err != nil {
|
||||
tmpFile.Close()
|
||||
return errUnavailable
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Use osascript to set clipboard to the image file
|
||||
script := fmt.Sprintf(`
|
||||
set theFile to POSIX file "%s"
|
||||
set theImage to read theFile as «class PNGf»
|
||||
set the clipboard to theImage
|
||||
`, tmpFile.Name())
|
||||
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ti := time.NewTicker(time.Second)
|
||||
|
||||
// Get initial clipboard content
|
||||
var lastContent []byte
|
||||
if b := Read(t); b != nil {
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(recv)
|
||||
defer ti.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ti.C:
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if content changed
|
||||
if !bytes.Equal(lastContent, b) {
|
||||
recv <- b
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return recv
|
||||
}
|
297
packages/tui/internal/clipboard/clipboard_linux.go
Normal file
297
packages/tui/internal/clipboard/clipboard_linux.go
Normal file
|
@ -0,0 +1,297 @@
|
|||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build linux
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Clipboard tools in order of preference
|
||||
clipboardTools = []struct {
|
||||
name string
|
||||
readCmd []string
|
||||
writeCmd []string
|
||||
readImg []string
|
||||
writeImg []string
|
||||
available bool
|
||||
}{
|
||||
{
|
||||
name: "xclip",
|
||||
readCmd: []string{"xclip", "-selection", "clipboard", "-o"},
|
||||
writeCmd: []string{"xclip", "-selection", "clipboard"},
|
||||
readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
|
||||
writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"},
|
||||
},
|
||||
{
|
||||
name: "xsel",
|
||||
readCmd: []string{"xsel", "--clipboard", "--output"},
|
||||
writeCmd: []string{"xsel", "--clipboard", "--input"},
|
||||
readImg: []string{"xsel", "--clipboard", "--output"},
|
||||
writeImg: []string{"xsel", "--clipboard", "--input"},
|
||||
},
|
||||
{
|
||||
name: "wl-clipboard",
|
||||
readCmd: []string{"wl-paste", "-n"},
|
||||
writeCmd: []string{"wl-copy"},
|
||||
readImg: []string{"wl-paste", "-t", "image/png", "-n"},
|
||||
writeImg: []string{"wl-copy", "-t", "image/png"},
|
||||
},
|
||||
}
|
||||
|
||||
selectedTool int = -1
|
||||
toolMutex sync.Mutex
|
||||
lastChangeTime time.Time
|
||||
changeTimeMu sync.Mutex
|
||||
)
|
||||
|
||||
func initialize() error {
|
||||
toolMutex.Lock()
|
||||
defer toolMutex.Unlock()
|
||||
|
||||
if selectedTool >= 0 {
|
||||
return nil // Already initialized
|
||||
}
|
||||
|
||||
// Check which clipboard tool is available
|
||||
for i, tool := range clipboardTools {
|
||||
cmd := exec.Command("which", tool.name)
|
||||
if err := cmd.Run(); err == nil {
|
||||
clipboardTools[i].available = true
|
||||
if selectedTool < 0 {
|
||||
selectedTool = i
|
||||
slog.Debug("Clipboard tool found", "tool", tool.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedTool < 0 {
|
||||
slog.Warn("No clipboard utility found on system. Copy/paste functionality will be disabled.")
|
||||
return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
|
||||
|
||||
For X11 systems:
|
||||
apt install -y xclip
|
||||
# or
|
||||
apt install -y xsel
|
||||
|
||||
For Wayland systems:
|
||||
apt install -y wl-clipboard
|
||||
|
||||
If running in a headless environment, you may also need:
|
||||
apt install -y xvfb
|
||||
# and run:
|
||||
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
|
||||
export DISPLAY=:99.0`, errUnavailable)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
// Ensure clipboard is initialized before attempting to read
|
||||
if err := initialize(); err != nil {
|
||||
slog.Debug("Clipboard read failed: not initialized", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolMutex.Lock()
|
||||
tool := clipboardTools[selectedTool]
|
||||
toolMutex.Unlock()
|
||||
|
||||
switch t {
|
||||
case FmtText:
|
||||
return readText(tool)
|
||||
case FmtImage:
|
||||
return readImage(tool)
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func readText(tool struct {
|
||||
name string
|
||||
readCmd []string
|
||||
writeCmd []string
|
||||
readImg []string
|
||||
writeImg []string
|
||||
available bool
|
||||
}) ([]byte, error) {
|
||||
// First check if clipboard contains text
|
||||
cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Check if it's because clipboard contains non-text data
|
||||
if tool.name == "xclip" {
|
||||
// xclip returns error when clipboard doesn't contain requested type
|
||||
checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
|
||||
targets, _ := checkCmd.Output()
|
||||
if bytes.Contains(targets, []byte("image/png")) && !bytes.Contains(targets, []byte("UTF8_STRING")) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
}
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readImage(tool struct {
|
||||
name string
|
||||
readCmd []string
|
||||
writeCmd []string
|
||||
readImg []string
|
||||
writeImg []string
|
||||
available bool
|
||||
}) ([]byte, error) {
|
||||
if tool.name == "xsel" {
|
||||
// xsel doesn't support image types well, return error
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Verify it's PNG data
|
||||
if len(out) < 8 || !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
// Ensure clipboard is initialized before attempting to write
|
||||
if err := initialize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolMutex.Lock()
|
||||
tool := clipboardTools[selectedTool]
|
||||
toolMutex.Unlock()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch t {
|
||||
case FmtText:
|
||||
if len(buf) == 0 {
|
||||
// Write empty string
|
||||
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
|
||||
cmd.Stdin = bytes.NewReader([]byte{})
|
||||
} else {
|
||||
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
|
||||
cmd.Stdin = bytes.NewReader(buf)
|
||||
}
|
||||
case FmtImage:
|
||||
if tool.name == "xsel" {
|
||||
// xsel doesn't support image types well
|
||||
return nil, errUnavailable
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
// Clear clipboard
|
||||
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
|
||||
cmd.Stdin = bytes.NewReader([]byte{})
|
||||
} else {
|
||||
cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...)
|
||||
cmd.Stdin = bytes.NewReader(buf)
|
||||
}
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Update change time
|
||||
changeTimeMu.Lock()
|
||||
lastChangeTime = time.Now()
|
||||
currentTime := lastChangeTime
|
||||
changeTimeMu.Unlock()
|
||||
|
||||
// Create change notification channel
|
||||
changed := make(chan struct{}, 1)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
changeTimeMu.Lock()
|
||||
if !lastChangeTime.Equal(currentTime) {
|
||||
changeTimeMu.Unlock()
|
||||
changed <- struct{}{}
|
||||
close(changed)
|
||||
return
|
||||
}
|
||||
changeTimeMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
|
||||
// Ensure clipboard is initialized before starting watch
|
||||
if err := initialize(); err != nil {
|
||||
close(recv)
|
||||
return recv
|
||||
}
|
||||
|
||||
ti := time.NewTicker(time.Second)
|
||||
|
||||
// Get initial clipboard content
|
||||
var lastContent []byte
|
||||
if b := Read(t); b != nil {
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(recv)
|
||||
defer ti.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ti.C:
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if content changed
|
||||
if !bytes.Equal(lastContent, b) {
|
||||
recv <- b
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return recv
|
||||
}
|
||||
|
||||
// Helper function to check clipboard content type for xclip
|
||||
func getClipboardTargets() []string {
|
||||
cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(string(out), "\n")
|
||||
}
|
25
packages/tui/internal/clipboard/clipboard_nocgo.go
Normal file
25
packages/tui/internal/clipboard/clipboard_nocgo.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
//go:build !windows && !darwin && !linux && !cgo
|
||||
|
||||
package clipboard
|
||||
|
||||
import "context"
|
||||
|
||||
func initialize() error {
|
||||
return errNoCgo
|
||||
}
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
func readc(t string) ([]byte, error) {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
551
packages/tui/internal/clipboard/clipboard_windows.go
Normal file
551
packages/tui/internal/clipboard/clipboard_windows.go
Normal file
|
@ -0,0 +1,551 @@
|
|||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build windows
|
||||
|
||||
package clipboard
|
||||
|
||||
// Interacting with Clipboard on Windows:
|
||||
// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/image/bmp"
|
||||
)
|
||||
|
||||
func initialize() error { return nil }
|
||||
|
||||
// readText reads the clipboard and returns the text data if presents.
|
||||
// The caller is responsible for opening/closing the clipboard before
|
||||
// calling this function.
|
||||
func readText() (buf []byte, err error) {
|
||||
hMem, _, err := getClipboardData.Call(cFmtUnicodeText)
|
||||
if hMem == 0 {
|
||||
return nil, err
|
||||
}
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return nil, err
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
// Find NUL terminator
|
||||
n := 0
|
||||
for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ {
|
||||
ptr = unsafe.Pointer(uintptr(ptr) +
|
||||
unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p)))))
|
||||
}
|
||||
|
||||
var s []uint16
|
||||
h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
|
||||
h.Data = p
|
||||
h.Len = n
|
||||
h.Cap = n
|
||||
return []byte(string(utf16.Decode(s))), nil
|
||||
}
|
||||
|
||||
// writeText writes given data to the clipboard. It is the caller's
|
||||
// responsibility for opening/closing the clipboard before calling
|
||||
// this function.
|
||||
func writeText(buf []byte) error {
|
||||
r, _, err := emptyClipboard.Call()
|
||||
if r == 0 {
|
||||
return fmt.Errorf("failed to clear clipboard: %w", err)
|
||||
}
|
||||
|
||||
// empty text, we are done here.
|
||||
if len(buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
s, err := syscall.UTF16FromString(string(buf))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert given string: %w", err)
|
||||
}
|
||||
|
||||
hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
|
||||
if hMem == 0 {
|
||||
return fmt.Errorf("failed to alloc global memory: %w", err)
|
||||
}
|
||||
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return fmt.Errorf("failed to lock global memory: %w", err)
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
// no return value
|
||||
memMove.Call(p, uintptr(unsafe.Pointer(&s[0])),
|
||||
uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
|
||||
|
||||
v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem)
|
||||
if v == 0 {
|
||||
gFree.Call(hMem)
|
||||
return fmt.Errorf("failed to set text to clipboard: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readImage reads the clipboard and returns PNG encoded image data
|
||||
// if presents. The caller is responsible for opening/closing the
|
||||
// clipboard before calling this function.
|
||||
func readImage() ([]byte, error) {
|
||||
hMem, _, err := getClipboardData.Call(cFmtDIBV5)
|
||||
if hMem == 0 {
|
||||
// second chance to try FmtDIB
|
||||
return readImageDib()
|
||||
}
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return nil, err
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
// inspect header information
|
||||
info := (*bitmapV5Header)(unsafe.Pointer(p))
|
||||
|
||||
// maybe deal with other formats?
|
||||
if info.BitCount != 32 {
|
||||
return nil, errUnsupported
|
||||
}
|
||||
|
||||
var data []byte
|
||||
sh := (*reflect.SliceHeader)(unsafe.Pointer(&data))
|
||||
sh.Data = uintptr(p)
|
||||
sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
|
||||
sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
|
||||
img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height)))
|
||||
offset := int(info.Size)
|
||||
stride := int(info.Width)
|
||||
for y := 0; y < int(info.Height); y++ {
|
||||
for x := 0; x < int(info.Width); x++ {
|
||||
idx := offset + 4*(y*stride+x)
|
||||
xhat := (x + int(info.Width)) % int(info.Width)
|
||||
yhat := int(info.Height) - 1 - y
|
||||
r := data[idx+2]
|
||||
g := data[idx+1]
|
||||
b := data[idx+0]
|
||||
a := data[idx+3]
|
||||
img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a})
|
||||
}
|
||||
}
|
||||
// always use PNG encoding.
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, img)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func readImageDib() ([]byte, error) {
|
||||
const (
|
||||
fileHeaderLen = 14
|
||||
infoHeaderLen = 40
|
||||
cFmtDIB = 8
|
||||
)
|
||||
|
||||
hClipDat, _, err := getClipboardData.Call(cFmtDIB)
|
||||
if err != nil {
|
||||
return nil, errors.New("not dib format data: " + err.Error())
|
||||
}
|
||||
pMemBlk, _, err := gLock.Call(hClipDat)
|
||||
if pMemBlk == 0 {
|
||||
return nil, errors.New("failed to call global lock: " + err.Error())
|
||||
}
|
||||
defer gUnlock.Call(hClipDat)
|
||||
|
||||
bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk))
|
||||
dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen
|
||||
|
||||
if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 {
|
||||
iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3)
|
||||
dataSize += iSizeImage
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8))
|
||||
binary.Write(buf, binary.LittleEndian, uint32(dataSize))
|
||||
binary.Write(buf, binary.LittleEndian, uint32(0))
|
||||
const sizeof_colorbar = 0
|
||||
binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar))
|
||||
j := 0
|
||||
for i := fileHeaderLen; i < int(dataSize); i++ {
|
||||
binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j))))
|
||||
j++
|
||||
}
|
||||
return bmpToPng(buf)
|
||||
}
|
||||
|
||||
func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) {
|
||||
var f bytes.Buffer
|
||||
original_image, err := bmp.Decode(bmpBuf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = png.Encode(&f, original_image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeImage(buf []byte) error {
|
||||
r, _, err := emptyClipboard.Call()
|
||||
if r == 0 {
|
||||
return fmt.Errorf("failed to clear clipboard: %w", err)
|
||||
}
|
||||
|
||||
// empty text, we are done here.
|
||||
if len(buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
img, err := png.Decode(bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return fmt.Errorf("input bytes is not PNG encoded: %w", err)
|
||||
}
|
||||
|
||||
offset := unsafe.Sizeof(bitmapV5Header{})
|
||||
width := img.Bounds().Dx()
|
||||
height := img.Bounds().Dy()
|
||||
imageSize := 4 * width * height
|
||||
|
||||
data := make([]byte, int(offset)+imageSize)
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
idx := int(offset) + 4*(y*width+x)
|
||||
r, g, b, a := img.At(x, height-1-y).RGBA()
|
||||
data[idx+2] = uint8(r)
|
||||
data[idx+1] = uint8(g)
|
||||
data[idx+0] = uint8(b)
|
||||
data[idx+3] = uint8(a)
|
||||
}
|
||||
}
|
||||
|
||||
info := bitmapV5Header{}
|
||||
info.Size = uint32(offset)
|
||||
info.Width = int32(width)
|
||||
info.Height = int32(height)
|
||||
info.Planes = 1
|
||||
info.Compression = 0 // BI_RGB
|
||||
info.SizeImage = uint32(4 * info.Width * info.Height)
|
||||
info.RedMask = 0xff0000 // default mask
|
||||
info.GreenMask = 0xff00
|
||||
info.BlueMask = 0xff
|
||||
info.AlphaMask = 0xff000000
|
||||
info.BitCount = 32 // we only deal with 32 bpp at the moment.
|
||||
// Use calibrated RGB values as Go's image/png assumes linear color space.
|
||||
// Other options:
|
||||
// - LCS_CALIBRATED_RGB = 0x00000000
|
||||
// - LCS_sRGB = 0x73524742
|
||||
// - LCS_WINDOWS_COLOR_SPACE = 0x57696E20
|
||||
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f
|
||||
info.CSType = 0x73524742
|
||||
// Use GL_IMAGES for GamutMappingIntent
|
||||
// Other options:
|
||||
// - LCS_GM_ABS_COLORIMETRIC = 0x00000008
|
||||
// - LCS_GM_BUSINESS = 0x00000001
|
||||
// - LCS_GM_GRAPHICS = 0x00000002
|
||||
// - LCS_GM_IMAGES = 0x00000004
|
||||
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38
|
||||
info.Intent = 4 // LCS_GM_IMAGES
|
||||
|
||||
infob := make([]byte, int(unsafe.Sizeof(info)))
|
||||
for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) {
|
||||
infob[i] = v
|
||||
}
|
||||
copy(data[:], infob[:])
|
||||
|
||||
hMem, _, err := gAlloc.Call(gmemMoveable,
|
||||
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
|
||||
if hMem == 0 {
|
||||
return fmt.Errorf("failed to alloc global memory: %w", err)
|
||||
}
|
||||
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return fmt.Errorf("failed to lock global memory: %w", err)
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
memMove.Call(p, uintptr(unsafe.Pointer(&data[0])),
|
||||
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
|
||||
|
||||
v, _, err := setClipboardData.Call(cFmtDIBV5, hMem)
|
||||
if v == 0 {
|
||||
gFree.Call(hMem)
|
||||
return fmt.Errorf("failed to set text to clipboard: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
// On Windows, OpenClipboard and CloseClipboard must be executed on
|
||||
// the same thread. Thus, lock the OS thread for further execution.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
var format uintptr
|
||||
switch t {
|
||||
case FmtImage:
|
||||
format = cFmtDIBV5
|
||||
case FmtText:
|
||||
fallthrough
|
||||
default:
|
||||
format = cFmtUnicodeText
|
||||
}
|
||||
|
||||
// check if clipboard is avaliable for the requested format
|
||||
r, _, err := isClipboardFormatAvailable.Call(format)
|
||||
if r == 0 {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// try again until open clipboard successed
|
||||
for {
|
||||
r, _, _ = openClipboard.Call()
|
||||
if r == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
defer closeClipboard.Call()
|
||||
|
||||
switch format {
|
||||
case cFmtDIBV5:
|
||||
return readImage()
|
||||
case cFmtUnicodeText:
|
||||
fallthrough
|
||||
default:
|
||||
return readText()
|
||||
}
|
||||
}
|
||||
|
||||
// write writes the given data to clipboard and
|
||||
// returns true if success or false if failed.
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
errch := make(chan error)
|
||||
changed := make(chan struct{}, 1)
|
||||
go func() {
|
||||
// make sure GetClipboardSequenceNumber happens with
|
||||
// OpenClipboard on the same thread.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
for {
|
||||
r, _, _ := openClipboard.Call(0)
|
||||
if r == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// var param uintptr
|
||||
switch t {
|
||||
case FmtImage:
|
||||
err := writeImage(buf)
|
||||
if err != nil {
|
||||
errch <- err
|
||||
closeClipboard.Call()
|
||||
return
|
||||
}
|
||||
case FmtText:
|
||||
fallthrough
|
||||
default:
|
||||
// param = cFmtUnicodeText
|
||||
err := writeText(buf)
|
||||
if err != nil {
|
||||
errch <- err
|
||||
closeClipboard.Call()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Close the clipboard otherwise other applications cannot
|
||||
// paste the data.
|
||||
closeClipboard.Call()
|
||||
|
||||
cnt, _, _ := getClipboardSequenceNumber.Call()
|
||||
errch <- nil
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
cur, _, _ := getClipboardSequenceNumber.Call()
|
||||
if cur != cnt {
|
||||
changed <- struct{}{}
|
||||
close(changed)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
err := <-errch
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ready := make(chan struct{})
|
||||
go func() {
|
||||
// not sure if we are too slow or the user too fast :)
|
||||
ti := time.NewTicker(time.Second)
|
||||
cnt, _, _ := getClipboardSequenceNumber.Call()
|
||||
ready <- struct{}{}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(recv)
|
||||
return
|
||||
case <-ti.C:
|
||||
cur, _, _ := getClipboardSequenceNumber.Call()
|
||||
if cnt != cur {
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
recv <- b
|
||||
cnt = cur
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
<-ready
|
||||
return recv
|
||||
}
|
||||
|
||||
const (
|
||||
cFmtBitmap = 2 // Win+PrintScreen
|
||||
cFmtUnicodeText = 13
|
||||
cFmtDIBV5 = 17
|
||||
// Screenshot taken from special shortcut is in different format (why??), see:
|
||||
// https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/
|
||||
cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats
|
||||
gmemMoveable = 0x0002
|
||||
)
|
||||
|
||||
// BITMAPV5Header structure, see:
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
|
||||
type bitmapV5Header struct {
|
||||
Size uint32
|
||||
Width int32
|
||||
Height int32
|
||||
Planes uint16
|
||||
BitCount uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter int32
|
||||
YPelsPerMeter int32
|
||||
ClrUsed uint32
|
||||
ClrImportant uint32
|
||||
RedMask uint32
|
||||
GreenMask uint32
|
||||
BlueMask uint32
|
||||
AlphaMask uint32
|
||||
CSType uint32
|
||||
Endpoints struct {
|
||||
CiexyzRed, CiexyzGreen, CiexyzBlue struct {
|
||||
CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30
|
||||
}
|
||||
}
|
||||
GammaRed uint32
|
||||
GammaGreen uint32
|
||||
GammaBlue uint32
|
||||
Intent uint32
|
||||
ProfileData uint32
|
||||
ProfileSize uint32
|
||||
Reserved uint32
|
||||
}
|
||||
|
||||
type bitmapHeader struct {
|
||||
Size uint32
|
||||
Width uint32
|
||||
Height uint32
|
||||
PLanes uint16
|
||||
BitCount uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter uint32
|
||||
YPelsPerMeter uint32
|
||||
ClrUsed uint32
|
||||
ClrImportant uint32
|
||||
}
|
||||
|
||||
// Calling a Windows DLL, see:
|
||||
// https://github.com/golang/go/wiki/WindowsDLLs
|
||||
var (
|
||||
user32 = syscall.MustLoadDLL("user32")
|
||||
// Opens the clipboard for examination and prevents other
|
||||
// applications from modifying the clipboard content.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard
|
||||
openClipboard = user32.MustFindProc("OpenClipboard")
|
||||
// Closes the clipboard.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard
|
||||
closeClipboard = user32.MustFindProc("CloseClipboard")
|
||||
// Empties the clipboard and frees handles to data in the clipboard.
|
||||
// The function then assigns ownership of the clipboard to the
|
||||
// window that currently has the clipboard open.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard
|
||||
emptyClipboard = user32.MustFindProc("EmptyClipboard")
|
||||
// Retrieves data from the clipboard in a specified format.
|
||||
// The clipboard must have been opened previously.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata
|
||||
getClipboardData = user32.MustFindProc("GetClipboardData")
|
||||
// Places data on the clipboard in a specified clipboard format.
|
||||
// The window must be the current clipboard owner, and the
|
||||
// application must have called the OpenClipboard function. (When
|
||||
// responding to the WM_RENDERFORMAT message, the clipboard owner
|
||||
// must not call OpenClipboard before calling SetClipboardData.)
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
|
||||
setClipboardData = user32.MustFindProc("SetClipboardData")
|
||||
// Determines whether the clipboard contains data in the specified format.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
|
||||
isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
|
||||
// Clipboard data formats are stored in an ordered list. To perform
|
||||
// an enumeration of clipboard data formats, you make a series of
|
||||
// calls to the EnumClipboardFormats function. For each call, the
|
||||
// format parameter specifies an available clipboard format, and the
|
||||
// function returns the next available clipboard format.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
|
||||
enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats")
|
||||
// Retrieves the clipboard sequence number for the current window station.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber
|
||||
getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber")
|
||||
// Registers a new clipboard format. This format can then be used as
|
||||
// a valid clipboard format.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata
|
||||
registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA")
|
||||
|
||||
kernel32 = syscall.NewLazyDLL("kernel32")
|
||||
|
||||
// Locks a global memory object and returns a pointer to the first
|
||||
// byte of the object's memory block.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock
|
||||
gLock = kernel32.NewProc("GlobalLock")
|
||||
// Decrements the lock count associated with a memory object that was
|
||||
// allocated with GMEM_MOVEABLE. This function has no effect on memory
|
||||
// objects allocated with GMEM_FIXED.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock
|
||||
gUnlock = kernel32.NewProc("GlobalUnlock")
|
||||
// Allocates the specified number of bytes from the heap.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc
|
||||
gAlloc = kernel32.NewProc("GlobalAlloc")
|
||||
// Frees the specified global memory object and invalidates its handle.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree
|
||||
gFree = kernel32.NewProc("GlobalFree")
|
||||
memMove = kernel32.NewProc("RtlMoveMemory")
|
||||
)
|
|
@ -29,7 +29,7 @@ type Command struct {
|
|||
Name CommandName
|
||||
Description string
|
||||
Keybindings []Keybinding
|
||||
Trigger string
|
||||
Trigger []string
|
||||
}
|
||||
|
||||
func (c Command) Keys() []string {
|
||||
|
@ -40,6 +40,21 @@ func (c Command) Keys() []string {
|
|||
return keys
|
||||
}
|
||||
|
||||
func (c Command) HasTrigger() bool {
|
||||
return len(c.Trigger) > 0
|
||||
}
|
||||
|
||||
func (c Command) PrimaryTrigger() string {
|
||||
if len(c.Trigger) > 0 {
|
||||
return c.Trigger[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c Command) MatchesTrigger(trigger string) bool {
|
||||
return slices.Contains(c.Trigger, trigger)
|
||||
}
|
||||
|
||||
type CommandRegistry map[CommandName]Command
|
||||
|
||||
func (r CommandRegistry) Sorted() []Command {
|
||||
|
@ -135,37 +150,37 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||
Name: AppHelpCommand,
|
||||
Description: "show help",
|
||||
Keybindings: parseBindings("<leader>h"),
|
||||
Trigger: "help",
|
||||
Trigger: []string{"help"},
|
||||
},
|
||||
{
|
||||
Name: EditorOpenCommand,
|
||||
Description: "open editor",
|
||||
Keybindings: parseBindings("<leader>e"),
|
||||
Trigger: "editor",
|
||||
Trigger: []string{"editor"},
|
||||
},
|
||||
{
|
||||
Name: SessionNewCommand,
|
||||
Description: "new session",
|
||||
Keybindings: parseBindings("<leader>n"),
|
||||
Trigger: "new",
|
||||
Trigger: []string{"new", "clear"},
|
||||
},
|
||||
{
|
||||
Name: SessionListCommand,
|
||||
Description: "list sessions",
|
||||
Keybindings: parseBindings("<leader>l"),
|
||||
Trigger: "sessions",
|
||||
Trigger: []string{"sessions", "resume", "continue"},
|
||||
},
|
||||
{
|
||||
Name: SessionShareCommand,
|
||||
Description: "share session",
|
||||
Keybindings: parseBindings("<leader>s"),
|
||||
Trigger: "share",
|
||||
Trigger: []string{"share"},
|
||||
},
|
||||
{
|
||||
Name: SessionUnshareCommand,
|
||||
Description: "unshare session",
|
||||
Keybindings: parseBindings("<leader>u"),
|
||||
Trigger: "unshare",
|
||||
Trigger: []string{"unshare"},
|
||||
},
|
||||
{
|
||||
Name: SessionInterruptCommand,
|
||||
|
@ -176,31 +191,31 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||
Name: SessionCompactCommand,
|
||||
Description: "compact the session",
|
||||
Keybindings: parseBindings("<leader>c"),
|
||||
Trigger: "compact",
|
||||
Trigger: []string{"compact", "summarize"},
|
||||
},
|
||||
{
|
||||
Name: ToolDetailsCommand,
|
||||
Description: "toggle tool details",
|
||||
Keybindings: parseBindings("<leader>d"),
|
||||
Trigger: "details",
|
||||
Trigger: []string{"details"},
|
||||
},
|
||||
{
|
||||
Name: ModelListCommand,
|
||||
Description: "list models",
|
||||
Keybindings: parseBindings("<leader>m"),
|
||||
Trigger: "models",
|
||||
Trigger: []string{"models"},
|
||||
},
|
||||
{
|
||||
Name: ThemeListCommand,
|
||||
Description: "list themes",
|
||||
Keybindings: parseBindings("<leader>t"),
|
||||
Trigger: "themes",
|
||||
Trigger: []string{"themes"},
|
||||
},
|
||||
{
|
||||
Name: FileListCommand,
|
||||
Description: "list files",
|
||||
Keybindings: parseBindings("<leader>f"),
|
||||
Trigger: "files",
|
||||
Trigger: []string{"files"},
|
||||
},
|
||||
{
|
||||
Name: FileCloseCommand,
|
||||
|
@ -221,7 +236,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||
Name: ProjectInitCommand,
|
||||
Description: "create/update AGENTS.md",
|
||||
Keybindings: parseBindings("<leader>i"),
|
||||
Trigger: "init",
|
||||
Trigger: []string{"init"},
|
||||
},
|
||||
{
|
||||
Name: InputClearCommand,
|
||||
|
@ -231,7 +246,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||
{
|
||||
Name: InputPasteCommand,
|
||||
Description: "paste content",
|
||||
Keybindings: parseBindings("ctrl+v"),
|
||||
Keybindings: parseBindings("ctrl+v", "super+v"),
|
||||
},
|
||||
{
|
||||
Name: InputSubmitCommand,
|
||||
|
@ -302,7 +317,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||
Name: AppExitCommand,
|
||||
Description: "exit the app",
|
||||
Keybindings: parseBindings("ctrl+c", "<leader>q"),
|
||||
Trigger: "exit",
|
||||
Trigger: []string{"exit", "quit"},
|
||||
},
|
||||
}
|
||||
registry := make(CommandRegistry)
|
||||
|
|
|
@ -31,7 +31,7 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
|||
|
||||
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
|
||||
spacer := strings.Repeat(" ", space)
|
||||
title := " /" + cmd.Trigger + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
|
||||
title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
|
||||
value := string(cmd.Name)
|
||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
|
@ -45,8 +45,8 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
|||
|
||||
space := 1
|
||||
for _, cmd := range c.app.Commands {
|
||||
if lipgloss.Width(cmd.Trigger) > space {
|
||||
space = lipgloss.Width(cmd.Trigger)
|
||||
if cmd.HasTrigger() && lipgloss.Width(cmd.PrimaryTrigger()) > space {
|
||||
space = lipgloss.Width(cmd.PrimaryTrigger())
|
||||
}
|
||||
}
|
||||
space += 2
|
||||
|
@ -56,10 +56,10 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
|||
// If no query, return all commands
|
||||
items := []dialog.CompletionItemI{}
|
||||
for _, cmd := range sorted {
|
||||
if cmd.Trigger == "" {
|
||||
if !cmd.HasTrigger() {
|
||||
continue
|
||||
}
|
||||
space := space - lipgloss.Width(cmd.Trigger)
|
||||
space := space - lipgloss.Width(cmd.PrimaryTrigger())
|
||||
items = append(items, getCommandCompletionItem(cmd, space, t))
|
||||
}
|
||||
return items, nil
|
||||
|
@ -70,12 +70,15 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
|||
commandMap := make(map[string]dialog.CompletionItemI)
|
||||
|
||||
for _, cmd := range sorted {
|
||||
if cmd.Trigger == "" {
|
||||
if !cmd.HasTrigger() {
|
||||
continue
|
||||
}
|
||||
space := space - lipgloss.Width(cmd.Trigger)
|
||||
commandNames = append(commandNames, cmd.Trigger)
|
||||
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space, t)
|
||||
space := space - lipgloss.Width(cmd.PrimaryTrigger())
|
||||
// Add all triggers as searchable options
|
||||
for _, trigger := range cmd.Trigger {
|
||||
commandNames = append(commandNames, trigger)
|
||||
commandMap[trigger] = getCommandCompletionItem(cmd, space, t)
|
||||
}
|
||||
}
|
||||
|
||||
// Find fuzzy matches
|
||||
|
|
|
@ -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 {
|
||||
|
@ -43,7 +42,7 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
|
|||
})
|
||||
|
||||
for _, file := range files {
|
||||
title := file.File
|
||||
title := file.Path
|
||||
if file.Added > 0 {
|
||||
title += green(" +" + strconv.Itoa(int(file.Added)))
|
||||
}
|
||||
|
@ -52,7 +51,7 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
|
|||
}
|
||||
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
Value: file.File,
|
||||
Value: file.Path,
|
||||
})
|
||||
items = append(items, item)
|
||||
}
|
||||
|
@ -108,8 +107,9 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
|
|||
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
||||
cg := &filesAndFoldersContextGroup{
|
||||
app: app,
|
||||
prefix: "file",
|
||||
}
|
||||
go func() {
|
||||
cg.gitFiles = cg.getGitFiles()
|
||||
}()
|
||||
return cg
|
||||
}
|
||||
|
|
|
@ -1,18 +1,24 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"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/clipboard"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/textarea"
|
||||
"github.com/sst/opencode/internal/image"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
|
@ -24,6 +30,7 @@ type EditorComponent interface {
|
|||
Content(width int) string
|
||||
Lines() int
|
||||
Value() string
|
||||
Length() int
|
||||
Focused() bool
|
||||
Focus() (tea.Model, tea.Cmd)
|
||||
Blur()
|
||||
|
@ -32,14 +39,15 @@ type EditorComponent interface {
|
|||
Paste() (tea.Model, tea.Cmd)
|
||||
Newline() (tea.Model, tea.Cmd)
|
||||
SetInterruptKeyInDebounce(inDebounce bool)
|
||||
SetExitKeyInDebounce(inDebounce bool)
|
||||
}
|
||||
|
||||
type editorComponent struct {
|
||||
app *app.App
|
||||
textarea textarea.Model
|
||||
attachments []app.Attachment
|
||||
spinner spinner.Model
|
||||
interruptKeyInDebounce bool
|
||||
exitKeyInDebounce bool
|
||||
}
|
||||
|
||||
func (m *editorComponent) Init() tea.Cmd {
|
||||
|
@ -61,22 +69,116 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
case tea.PasteMsg:
|
||||
text := string(msg)
|
||||
text = strings.ReplaceAll(text, "\\", "")
|
||||
text, err := strconv.Unquote(`"` + text + `"`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unquote text", "error", err)
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
return m, nil
|
||||
}
|
||||
if _, err := os.Stat(text); err != nil {
|
||||
slog.Error("Failed to paste file", "error", err)
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
filePath := text
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
mediaType := ""
|
||||
switch ext {
|
||||
case ".jpg":
|
||||
mediaType = "image/jpeg"
|
||||
case ".png", ".jpeg", ".gif", ".webp":
|
||||
mediaType = "image/" + ext[1:]
|
||||
case ".pdf":
|
||||
mediaType = "application/pdf"
|
||||
default:
|
||||
mediaType = "text/plain"
|
||||
}
|
||||
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read file", "error", err)
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
return m, nil
|
||||
}
|
||||
base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
|
||||
url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
|
||||
attachmentCount := len(m.textarea.GetAttachments())
|
||||
attachmentIndex := attachmentCount + 1
|
||||
label := "File"
|
||||
if strings.HasPrefix(mediaType, "image/") {
|
||||
label = "Image"
|
||||
}
|
||||
|
||||
attachment := &textarea.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
MediaType: mediaType,
|
||||
Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
|
||||
URL: url,
|
||||
Filename: filePath,
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
case tea.ClipboardMsg:
|
||||
text := string(msg)
|
||||
m.textarea.InsertRunesFromUserInput([]rune(text))
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.textarea = createTextArea(&m.textarea)
|
||||
m.textarea = m.resetTextareaStyles()
|
||||
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 + " ")
|
||||
|
@ -125,10 +227,21 @@ func (m *editorComponent) Content(width int) string {
|
|||
Render(textarea)
|
||||
|
||||
hint := base(m.getSubmitKeyText()) + muted(" send ")
|
||||
if m.app.IsBusy() {
|
||||
if m.exitKeyInDebounce {
|
||||
keyText := m.getExitKeyText()
|
||||
hint = base(keyText+" again") + muted(" to exit")
|
||||
} else 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")
|
||||
}
|
||||
|
@ -183,6 +296,10 @@ func (m *editorComponent) Value() string {
|
|||
return m.textarea.Value()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Length() int {
|
||||
return m.textarea.Length()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||
value := strings.TrimSpace(m.Value())
|
||||
if value == "" {
|
||||
|
@ -190,19 +307,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,19 +339,31 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
||||
imageBytes, text, err := image.GetImageFromClipboard()
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
imageBytes := clipboard.Read(clipboard.FmtImage)
|
||||
if imageBytes != nil {
|
||||
attachmentCount := len(m.textarea.GetAttachments())
|
||||
attachmentIndex := attachmentCount + 1
|
||||
base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
attachment := &textarea.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
MediaType: "image/png",
|
||||
Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
|
||||
Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
|
||||
URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
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)
|
||||
}
|
||||
|
||||
textBytes := clipboard.Read(clipboard.FmtText)
|
||||
if textBytes != nil {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(string(textBytes)))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// fallback to reading the clipboard using OSC52
|
||||
return m, tea.ReadClipboard
|
||||
}
|
||||
|
||||
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
|
||||
|
@ -236,6 +375,10 @@ func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
|||
m.interruptKeyInDebounce = inDebounce
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
|
||||
m.exitKeyInDebounce = inDebounce
|
||||
}
|
||||
|
||||
func (m *editorComponent) getInterruptKeyText() string {
|
||||
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
|
||||
}
|
||||
|
@ -244,34 +387,41 @@ func (m *editorComponent) getSubmitKeyText() string {
|
|||
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
|
||||
}
|
||||
|
||||
func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
func (m *editorComponent) getExitKeyText() string {
|
||||
return m.app.Commands[commands.AppExitCommand].Keys()[0]
|
||||
}
|
||||
|
||||
func (m *editorComponent) resetTextareaStyles() textarea.Model {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
ta := textarea.New()
|
||||
ta := m.textarea
|
||||
|
||||
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 = " "
|
||||
ta.ShowLineNumbers = false
|
||||
ta.CharLimit = -1
|
||||
|
||||
if existing != nil {
|
||||
ta.SetValue(existing.Value())
|
||||
// ta.SetWidth(existing.Width())
|
||||
ta.SetHeight(existing.Height())
|
||||
}
|
||||
|
||||
return ta
|
||||
}
|
||||
|
||||
|
@ -291,12 +441,19 @@ func createSpinner() spinner.Model {
|
|||
|
||||
func NewEditorComponent(app *app.App) EditorComponent {
|
||||
s := createSpinner()
|
||||
ta := createTextArea(nil)
|
||||
|
||||
return &editorComponent{
|
||||
ta := textarea.New()
|
||||
ta.Prompt = " "
|
||||
ta.ShowLineNumbers = false
|
||||
ta.CharLimit = -1
|
||||
|
||||
m := &editorComponent{
|
||||
app: app,
|
||||
textarea: ta,
|
||||
spinner: s,
|
||||
interruptKeyInDebounce: false,
|
||||
}
|
||||
m.resetTextareaStyles()
|
||||
|
||||
return m
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
@ -135,7 +134,6 @@ func renderContentBlock(
|
|||
style := styles.NewStyle().
|
||||
Foreground(renderer.textColor).
|
||||
Background(t.BackgroundPanel()).
|
||||
Width(width).
|
||||
PaddingTop(renderer.paddingTop).
|
||||
PaddingBottom(renderer.paddingBottom).
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
|
@ -217,17 +215,34 @@ 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()
|
||||
}
|
||||
var content string
|
||||
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 := styles.NewStyle().Background(backgroundColor).Width(width - 6)
|
||||
content = messageStyle.Render(text)
|
||||
}
|
||||
|
||||
timestamp := ts.
|
||||
Local().
|
||||
Format("02 Jan 2006 03:04 PM")
|
||||
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
|
||||
|
@ -237,30 +252,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 +266,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 +282,7 @@ func renderText(
|
|||
WithTextColor(t.Text()),
|
||||
WithBorderColorRight(t.Secondary()),
|
||||
)
|
||||
case opencode.MessageRoleAssistant:
|
||||
case opencode.AssistantMessage:
|
||||
return renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
|
@ -295,39 +296,34 @@ 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)
|
||||
title = styles.NewStyle().Width(width - 6).Render(title)
|
||||
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,17 +339,19 @@ func renderToolDetails(
|
|||
borderColor = t.BorderActive()
|
||||
}
|
||||
|
||||
switch toolCall.ToolInvocation.ToolName {
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
|
||||
metadata := toolCall.State.Metadata.(map[string]any)
|
||||
switch toolCall.Tool {
|
||||
case "read":
|
||||
preview := metadata.ExtraFields["preview"]
|
||||
if preview != nil && toolArgsMap["filePath"] != nil {
|
||||
filename := toolArgsMap["filePath"].(string)
|
||||
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 "edit":
|
||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||
diffField := metadata.ExtraFields["diff"]
|
||||
if filename, ok := toolInputMap["filePath"].(string); ok {
|
||||
diffField := metadata["diff"]
|
||||
if diffField != nil {
|
||||
patch := diffField.(string)
|
||||
var formattedDiff string
|
||||
|
@ -378,7 +375,7 @@ func renderToolDetails(
|
|||
body += "\n" + diagnostics
|
||||
}
|
||||
|
||||
title := renderToolTitle(toolCall, messageMetadata, width)
|
||||
title := renderToolTitle(toolCall, width)
|
||||
title = style.Render(title)
|
||||
content := title + "\n" + body
|
||||
content = renderContentBlock(
|
||||
|
@ -393,8 +390,8 @@ func renderToolDetails(
|
|||
}
|
||||
}
|
||||
case "write":
|
||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||
if content, ok := toolArgsMap["content"].(string); ok {
|
||||
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
|
||||
|
@ -402,14 +399,14 @@ func renderToolDetails(
|
|||
}
|
||||
}
|
||||
case "bash":
|
||||
stdout := metadata.ExtraFields["stdout"]
|
||||
stdout := metadata["stdout"]
|
||||
if stdout != nil {
|
||||
command := toolArgsMap["command"].(string)
|
||||
command := toolInputMap["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 {
|
||||
if format, ok := toolInputMap["format"].(string); ok && result != nil {
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
if format == "html" || format == "markdown" {
|
||||
|
@ -417,15 +414,16 @@ func renderToolDetails(
|
|||
}
|
||||
}
|
||||
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() {
|
||||
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 "cancelled":
|
||||
body += fmt.Sprintf("- [~] %s\n", content)
|
||||
// case "in-progress":
|
||||
// body += fmt.Sprintf("- [ ] %s\n", content)
|
||||
default:
|
||||
|
@ -435,30 +433,21 @@ func renderToolDetails(
|
|||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
case "task":
|
||||
summary := metadata.JSON.ExtraFields["summary"]
|
||||
if !summary.IsNull() {
|
||||
strValue := summary.Raw()
|
||||
toolcalls := gjson.Parse(strValue).Array()
|
||||
|
||||
summary := metadata["summary"]
|
||||
if summary != nil {
|
||||
toolcalls := summary.([]any)
|
||||
steps := []string{}
|
||||
for _, toolcall := range toolcalls {
|
||||
call := toolcall.Value().(map[string]any)
|
||||
call := toolcall.(map[string]any)
|
||||
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
|
||||
data, _ := json.Marshal(toolInvocation)
|
||||
var toolCall opencode.ToolInvocationPart
|
||||
var toolCall opencode.ToolPart
|
||||
_ = 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)
|
||||
step := renderToolTitle(toolCall, width)
|
||||
step = "∟ " + step
|
||||
steps = append(steps, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
body = strings.Join(steps, "\n")
|
||||
}
|
||||
default:
|
||||
|
@ -468,17 +457,18 @@ func renderToolDetails(
|
|||
}
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
body = styles.NewStyle().Width(width - 6).Render(body)
|
||||
}
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
body = styles.NewStyle().
|
||||
Width(width - 6).
|
||||
Foreground(t.Error()).
|
||||
Background(backgroundColor).
|
||||
Render(error)
|
||||
|
@ -487,9 +477,10 @@ func renderToolDetails(
|
|||
if body == "" && error == "" && result != nil {
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
body = styles.NewStyle().Width(width - 6).Render(body)
|
||||
}
|
||||
|
||||
title := renderToolTitle(toolCall, messageMetadata, width)
|
||||
title := renderToolTitle(toolCall, width)
|
||||
content := title + "\n\n" + body
|
||||
return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor))
|
||||
}
|
||||
|
@ -510,20 +501,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 +531,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 +550,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 +630,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,24 +194,26 @@ 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
|
||||
|
@ -184,9 +226,9 @@ func (m *messagesComponent) renderView(width int) {
|
|||
// we only want tool calls associated with the current text part.
|
||||
// if we hit another text part, we're done.
|
||||
remaining = false
|
||||
case opencode.ToolInvocationPart:
|
||||
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
|
||||
|
@ -195,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)
|
||||
|
@ -215,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...,
|
||||
)
|
||||
}
|
||||
|
@ -226,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)
|
||||
|
@ -234,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,
|
||||
|
@ -246,7 +290,6 @@ func (m *messagesComponent) renderView(width int) {
|
|||
content = renderToolDetails(
|
||||
m.app,
|
||||
part,
|
||||
message.Metadata,
|
||||
m.partCount == m.selectedPart,
|
||||
width,
|
||||
)
|
||||
|
@ -257,7 +300,6 @@ func (m *messagesComponent) renderView(width int) {
|
|||
content = renderToolDetails(
|
||||
m.app,
|
||||
part,
|
||||
message.Metadata,
|
||||
m.partCount == m.selectedPart,
|
||||
width,
|
||||
)
|
||||
|
@ -271,17 +313,22 @@ func (m *messagesComponent) renderView(width int) {
|
|||
}
|
||||
|
||||
error := ""
|
||||
switch err := message.Metadata.Error.AsUnion().(type) {
|
||||
if assistant, ok := message.(opencode.AssistantMessage); ok {
|
||||
switch err := assistant.Error.AsUnion().(type) {
|
||||
case nil:
|
||||
case opencode.MessageMetadataErrorMessageOutputLengthError:
|
||||
case opencode.AssistantMessageErrorMessageOutputLengthError:
|
||||
error = "Message output length exceeded"
|
||||
case opencode.ProviderAuthError:
|
||||
error = err.Data.Message
|
||||
case opencode.MessageAbortedError:
|
||||
error = "Request was aborted"
|
||||
case opencode.UnknownError:
|
||||
error = err.Data.Message
|
||||
}
|
||||
}
|
||||
|
||||
if error != "" {
|
||||
error = styles.NewStyle().Width(width - 6).Render(error)
|
||||
error = renderContentBlock(
|
||||
m.app,
|
||||
error,
|
||||
|
|
|
@ -56,8 +56,8 @@ func (c *commandsComponent) View() string {
|
|||
var untriggeredCommands []commands.Command
|
||||
|
||||
for _, cmd := range c.app.Commands.Sorted() {
|
||||
if c.showAll || cmd.Trigger != "" {
|
||||
if cmd.Trigger != "" {
|
||||
if c.showAll || cmd.HasTrigger() {
|
||||
if cmd.HasTrigger() {
|
||||
triggeredCommands = append(triggeredCommands, cmd)
|
||||
} else if c.showAll {
|
||||
untriggeredCommands = append(untriggeredCommands, cmd)
|
||||
|
@ -97,8 +97,8 @@ func (c *commandsComponent) View() string {
|
|||
|
||||
for _, cmd := range commandsToShow {
|
||||
trigger := ""
|
||||
if cmd.Trigger != "" {
|
||||
trigger = "/" + cmd.Trigger
|
||||
if cmd.HasTrigger() {
|
||||
trigger = "/" + cmd.PrimaryTrigger()
|
||||
} else {
|
||||
trigger = string(cmd.Name)
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package dialog
|
|||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
||||
|
@ -64,7 +65,7 @@ type CompletionProvider interface {
|
|||
type CompletionSelectedMsg struct {
|
||||
SearchString string
|
||||
CompletionValue string
|
||||
IsCommand bool
|
||||
ProviderID string
|
||||
}
|
||||
|
||||
type CompletionDialogCompleteItemMsg struct {
|
||||
|
@ -87,6 +88,7 @@ type completionDialogComponent struct {
|
|||
height int
|
||||
pseudoSearchTextArea textarea.Model
|
||||
list list.List[CompletionItemI]
|
||||
trigger string
|
||||
}
|
||||
|
||||
type completionDialogKeyMap struct {
|
||||
|
@ -119,11 +121,8 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
var query string
|
||||
query = c.pseudoSearchTextArea.Value()
|
||||
if query != "" {
|
||||
query = query[1:]
|
||||
}
|
||||
fullValue := c.pseudoSearchTextArea.Value()
|
||||
query := strings.TrimPrefix(fullValue, c.trigger)
|
||||
|
||||
if query != c.query {
|
||||
c.query = query
|
||||
|
@ -150,8 +149,9 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return c, c.complete(item)
|
||||
case key.Matches(msg, completionDialogKeys.Cancel):
|
||||
// Only close on backspace when there are no characters left
|
||||
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
|
||||
// Only close on backspace when there are no characters left, unless we're back to just the trigger
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
if msg.String() != "backspace" || (len(value) <= len(c.trigger) && value != c.trigger) {
|
||||
return c, c.close()
|
||||
}
|
||||
}
|
||||
|
@ -183,8 +183,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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -213,14 +214,11 @@ func (c *completionDialogComponent) IsEmpty() bool {
|
|||
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
|
||||
// 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(),
|
||||
)
|
||||
|
@ -232,7 +230,7 @@ func (c *completionDialogComponent) close() tea.Cmd {
|
|||
return util.CmdHandler(CompletionDialogCloseMsg{})
|
||||
}
|
||||
|
||||
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
|
||||
func NewCompletionDialogComponent(completionProvider CompletionProvider, trigger string) CompletionDialog {
|
||||
ti := textarea.New()
|
||||
|
||||
li := list.NewListComponent(
|
||||
|
@ -250,10 +248,14 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
|
|||
li.SetItems(items)
|
||||
}()
|
||||
|
||||
// Initialize the textarea with the trigger character
|
||||
ti.SetValue(trigger)
|
||||
|
||||
return &completionDialogComponent{
|
||||
query: "",
|
||||
completionProvider: completionProvider,
|
||||
pseudoSearchTextArea: ti,
|
||||
list: li,
|
||||
trigger: trigger,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -124,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).
|
||||
|
@ -171,7 +171,7 @@ 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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue