Compare commits

...

88 Commits

Author SHA1 Message Date
adamdottv f0c30861c5 fix: env has to be string
publish / publish (push) Waiting to run
2025-07-08 14:59:03 -05:00
adamdottv 5ea5ebbbdd fix(tui): pasting causes panic on macos 2025-07-08 14:57:17 -05:00
adamdottv b2a0eb3e6a fix(tui): model/provider arg parsing 2025-07-08 14:11:57 -05:00
Timo Clasen 1dd8a8c48a fix(copilot): add vision request header (#773) 2025-07-08 14:01:54 -05:00
adamdottv e5dee9c760 fix: image reading
publish / publish (push) Waiting to run
2025-07-08 13:02:13 -05:00
Aiden Cline d2575eea6c fix: display errors when using opencode run ... (#751) 2025-07-08 10:38:11 -05:00
adamdottv ea7c1f7ea6 fix: include symlinks in ripgrep searches 2025-07-08 10:02:19 -05:00
Dax eb30f5eb59 Update STATS.md 2025-07-08 10:30:02 -04:00
adamdottv 90c521226f feat(tui): command aliases 2025-07-08 08:20:55 -05:00
adamdottv 162497783b feat(tui): paste images and pdfs 2025-07-08 08:09:01 -05:00
GitHub Action 23327b47e6 ignore: update download stats 2025-07-08 2025-07-08 12:04:27 +00:00
GitHub Action 2daed93355 ignore: update download stats 2025-07-08 2025-07-08 10:51:06 +00:00
adamdottv 9007c6b36d feat(tui): handle --model and --prompt flags 2025-07-08 05:50:18 -05:00
Dax Raad 1d6edec2c2 start file watcher only for tui 2025-07-07 21:05:04 -04:00
Dax Raad ca926515b2 better handling of aborting sessions
publish / publish (push) Waiting to run
2025-07-07 20:59:00 -04:00
Jay V 713141175a docs: intro 2025-07-07 17:41:46 -04:00
Dax Raad 992bbcbd60 fix crash when running on new project
publish / publish (push) Waiting to run
2025-07-07 17:39:52 -04:00
Dax Raad 5cd08e988a fix mcp tools corrupting session
publish / publish (push) Waiting to run
2025-07-07 17:05:16 -04:00
Dax Raad 2a750c2333 run: truncate prompt 2025-07-07 16:41:42 -04:00
Jay V 2c4a516b47 docs: canonical url 2025-07-07 16:37:00 -04:00
Dax Raad 739716aef9 append piped stdin to prompt
publish / publish (push) Waiting to run
2025-07-07 16:33:21 -04:00
Jay V 1fe7c77504 docs: dynamic domain 2025-07-07 16:31:15 -04:00
Dax Raad 446932ceec Add stdin support to run command
Allow piping content to opencode run when no message arguments are provided, enabling standard Unix pipe patterns for better CLI integration.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-07-07 16:29:13 -04:00
Jay V 2a3f77d6c2 docs: debug 2025-07-07 16:26:23 -04:00
Dax Raad e8bbb3e7ab wip: model and prompt flags for tui 2025-07-07 16:24:37 -04:00
Jay V 1efcaade0f docs: debug info 2025-07-07 16:13:26 -04:00
Dax Raad 69ef316d7d fix interrupt
publish / publish (push) Waiting to run
2025-07-07 16:12:47 -04:00
Dax 9cad702612 v2 message format and upgrade to ai sdk v5 (#743)
publish / publish (push) Waiting to run
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Liang-Shih Lin <liangshihlin@proton.me>
Co-authored-by: Dominik Engelhardt <dominikengelhardt@ymail.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-07-07 15:53:43 -04:00
Jay V fad07dc6e7 docs: discord
publish / publish (push) Waiting to run
2025-07-07 14:44:37 -04:00
Dominik Engelhardt ca1f14a523 Fix Elixir LSP startup (#726) 2025-07-06 23:37:46 -04:00
Liang-Shih Lin d7b2f0079c fix: Skip opencode upgrade if same version (#720) 2025-07-06 23:36:59 -04:00
GitHub Action 65710a3bac Update download stats 2025-07-06 2025-07-06 12:03:56 +00:00
Dax Raad 207c73bc6a fix openrouter caching with anthropic, should be a lot cheaper 2025-07-05 11:39:54 -04:00
GitHub Action b5eead4dd1 Update download stats 2025-07-05 2025-07-05 12:03:56 +00:00
Dax Raad e5275df48e use full filepath
publish / publish (push) Waiting to run
2025-07-04 17:58:03 -04:00
Jay V a536d88f81 docs: fix show more in share page 2025-07-04 17:57:12 -04:00
Dax Raad 79d0545e0a fix input bar not rendering capital letters
publish / publish (push) Waiting to run
2025-07-04 17:21:51 -04:00
Timo Clasen 00e50865f1 fix(tui): optimistic rendering (#692) 2025-07-04 16:06:57 -05:00
Dax Raad 4a274ef8a0 make file attachments work good like
publish / publish (push) Waiting to run
2025-07-04 16:21:26 -04:00
Jay V 51e5be4f89 docs: share fix scrolling again 2025-07-04 13:53:25 -04:00
Jay V 1ee272138f docs: share improve markdown rendering of ai responses 2025-07-04 13:53:25 -04:00
Dax Raad f81777575a wip: fix media type 2025-07-04 12:50:52 -04:00
adamdottv 0848fc6164 fix(tui): full paths for attachments 2025-07-04 11:42:22 -05:00
Dax Raad 4d2ed3c930 file attachments 2025-07-04 12:24:01 -04:00
adamdottv 973a6708ce fix(tui): markdown wrapping off sometimes 2025-07-04 11:16:38 -05:00
adamdottv c399f75f2f fix(tui): handle pdf and image @ files 2025-07-04 11:13:09 -05:00
adamdottv 6270feab77 feat(tui): render attachments 2025-07-04 10:55:02 -05:00
adamdottv 9ac592b58b feat(tui): file attachments 2025-07-04 10:55:02 -05:00
adamdottv f062b39ad3 fix(tui): slower startup due to file.status 2025-07-04 10:55:01 -05:00
Dax Raad 37824a539f removed banned command concept 2025-07-04 11:32:12 -04:00
Vladimir 9f2c80d51d docs: Fix invalid json in the mcp example config (#645) 2025-07-04 11:24:13 -04:00
Aiden Cline 764375b5bb chore: document instructions configuration option (#670) 2025-07-04 11:22:45 -04:00
Dax Raad 76577d2db4 disable snapshots temporarily
publish / publish (push) Waiting to run
2025-07-04 08:45:18 -04:00
GitHub Action fd63682808 Update download stats 2025-07-04 2025-07-04 12:26:16 +00:00
Dax Raad 2ba7a4e337 improve snapshotting speed further
publish / publish (push) Waiting to run
2025-07-03 21:36:09 -04:00
Jay V 6d04925de2 docs: share fix scroll to anchor 2025-07-03 20:30:21 -04:00
Dax Raad 0e648fe1c3 ci: ignore 2025-07-03 20:30:02 -04:00
Dax Raad a59340b05d improve snapshot speed
publish / publish (push) Waiting to run
2025-07-03 20:16:25 -04:00
Jay V ac76a81354 docs: share fix last message not expandable 2025-07-03 19:33:55 -04:00
Dax Raad a0764b2c3a add elixir lsp support
publish / publish (push) Waiting to run
2025-07-03 19:29:51 -04:00
Dax Raad 0d798b90aa wip: logs 2025-07-03 19:29:51 -04:00
Jay V b14b9af9d8 docs: fix share page scroll performance 2025-07-03 19:15:38 -04:00
Jay V c627b7f4b2 docs: tweak page title 2025-07-03 16:23:08 -04:00
Timo Clasen a42379df25 chore: add dev script (#666) 2025-07-03 14:43:25 -05:00
Dax Raad 1843630b21 fix /unshare
publish / publish (push) Waiting to run
2025-07-03 15:34:04 -04:00
adamdottv d49a1304f8 chore: simplify completions 2025-07-03 12:48:22 -05:00
Jay V a7cfb68335 docs: share handle slow loading pages 2025-07-03 13:15:21 -04:00
adamdottv 0763cff26c chore: better local dev with stainless script 2025-07-03 11:49:15 -05:00
adamdottv dfff25a241 fix(tui): panic on completions failure
publish / publish (push) Waiting to run
2025-07-03 10:53:43 -05:00
adamdottv b7bd7c28e2 fix(tui): redundant tool calls in each message in collapsed mode
publish / publish (push) Waiting to run
2025-07-03 10:42:27 -05:00
Dax Raad 89821f3f69 flag for disabling file watcher 2025-07-03 10:37:08 -04:00
Dax Raad 7a4aa43f22 increase max retries to 10
publish / publish (push) Waiting to run
2025-07-03 10:32:55 -04:00
adamdottv 384ab92fe4 fix(tui): include orphaned tool calls 2025-07-03 09:32:44 -05:00
adamdottv fdeff1c5b4 fix(tui): selected message visuals 2025-07-03 09:03:04 -05:00
Dax Raad 36e70ddf8a fix webdomain 2025-07-03 09:58:25 -04:00
GitHub Action 5924c5170e Update download stats 2025-07-03 2025-07-03 12:26:51 +00:00
adamdottv f733412538 feat(tui): unshare command
publish / publish (push) Waiting to run
2025-07-03 07:09:09 -05:00
adamdottv 85d21de2bc fix(tui): always show status bar 2025-07-03 06:53:05 -05:00
adamdottv b99f76142b fix(tui): better highlight visuals 2025-07-03 06:49:37 -05:00
Craig Andrews a7ae9e2c7e fix: simplify parallel map using channels (#582) 2025-07-03 05:43:10 -05:00
Lev 685969001c fix: properly handle utf-8 in diff highlighting (#585) 2025-07-03 05:42:40 -05:00
Dax Raad d918a64481 do not install gopls if go is not installed
publish / publish (push) Waiting to run
2025-07-02 23:59:08 -04:00
Jacob Hands 0470bb18a0 fix: use correct opencode bin path when running in development mode (#483)
publish / publish (push) Waiting to run
2025-07-02 23:37:48 -04:00
Timo Clasen c2bee00385 fix: typescript error (#618) 2025-07-02 23:27:43 -04:00
Adi Yeroslav df7b7b7234 Update prompt reference from CLAUDE.md to AGENTS.md (#623) 2025-07-02 23:27:22 -04:00
Aiden Cline bd1d1f4dc0 feat: configurable instructions (#624) 2025-07-02 23:27:04 -04:00
andrewxt 05a535d1c3 fix mouse scroll events being interpreted as keyboard input (#628) 2025-07-02 23:26:09 -04:00
Dax Raad b09f93b6ee remove need for glibc
publish / publish (push) Waiting to run
2025-07-02 22:53:04 -04:00
210 changed files with 16998 additions and 7154 deletions
+1 -1
View File
@@ -28,5 +28,5 @@ jobs:
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add STATS.md
git diff --staged --quiet || git commit -m "Update download stats $(date -I)"
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
git push
+1
View File
@@ -5,3 +5,4 @@ node_modules
.env
.idea
.vscode
openapi.json
+2 -2
View File
@@ -9,7 +9,7 @@
</p>
<p align="center">AI coding agent, built for the terminal.</p>
<p align="center">
<a href="https://opencode.ai/docs"><img alt="View docs" src="https://img.shields.io/badge/view-docs-blue?style=flat-square" /></a>
<a href="https://discord.gg/opencode"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -76,4 +76,4 @@ The other confusingly named repo has no relation to this one. You can [read the
---
**Join our community** [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
**Join our community** [Discord](https://discord.gg/opencode) | [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
+11 -6
View File
@@ -1,8 +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) |
| 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) |
+45 -42
View File
@@ -5,7 +5,7 @@
"name": "opencode",
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6",
"sst": "3.17.8",
},
},
"packages/function": {
@@ -78,6 +78,7 @@
"lang-map": "0.4.0",
"luxon": "3.6.1",
"marked": "15.0.12",
"marked-shiki": "1.2.0",
"rehype-autolink-headings": "7.1.0",
"sharp": "0.32.5",
"shiki": "3.4.2",
@@ -95,30 +96,22 @@
"sharp",
"esbuild",
],
"patchedDependencies": {
"ai@4.3.16": "patches/ai@4.3.16.patch",
},
"overrides": {
"zod": "3.24.2",
},
"catalog": {
"@types/node": "22.13.9",
"ai": "4.3.16",
"ai": "5.0.0-beta.7",
"typescript": "5.8.2",
"zod": "3.24.2",
"zod": "3.25.49",
},
"packages": {
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@2.2.10", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icLGO7Q0NinnHIPgT+y1QjHVwH4HwV+brWbvM+FfCG2Afpa89PyKa3Ret91kGjZpBgM/xnj1B7K5eM+rRlsXQA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-beta.3", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-g49gMSkXy94lYvl5LRh438OR/0JCG6ol0jV+iLot7cy5HLltZlGocEuauETBu4b10mDXOd7XIjTEZoQpYFMYLQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-beta.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Z8SPncMtS3RsoXITmT7NVwrAq6M44dmw0DoUOYJqNNtCu8iMWuxB8Nxsoqpa0uEEy9R1V1ZThJAXTYgjTUxl3w=="],
"@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0-beta.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-H4K+4weOVgWqrDDeAbQWoA4U5mN4WrQPHQFdH7ynQYcnhj/pzctU9Q6mGlR5ESMWxaXxazxlOblSITlXo9bahA=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
@@ -462,12 +455,10 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@@ -492,6 +483,8 @@
"@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="],
"@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="],
@@ -512,7 +505,7 @@
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
"ai": ["ai@5.0.0-beta.7", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-beta.3", "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-oC4KzUJCQPMB7v9rCqL/rVk2ogZvI6lYiXfKjzPYHwa1zIgy329qqRLmAd3mKEDTTG6By1r0zasQu7FKmG+4gw=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -602,7 +595,7 @@
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -730,8 +723,6 @@
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
"diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
@@ -800,7 +791,7 @@
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
"eventsource-parser": ["eventsource-parser@3.0.3", "", {}, "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
@@ -1022,8 +1013,6 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
@@ -1050,6 +1039,8 @@
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"marked-shiki": ["marked-shiki@1.2.0", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-N924hp8veE6Mc91g5/kCNVoTU7TkeJfB2G2XEWb+k1fVA0Bck2T0rVt93d39BlOYH6ohP4Q9BFlPk+UkblhXbg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="],
@@ -1338,8 +1329,6 @@
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
@@ -1476,23 +1465,23 @@
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
"sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="],
"sst": ["sst@3.17.8", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.8", "sst-darwin-x64": "3.17.8", "sst-linux-arm64": "3.17.8", "sst-linux-x64": "3.17.8", "sst-linux-x86": "3.17.8", "sst-win32-arm64": "3.17.8", "sst-win32-x64": "3.17.8", "sst-win32-x86": "3.17.8" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-P/a9/ZsjtQRrTBerBMO1ODaVa5HVTmNLrQNJiYvu2Bgd0ov+vefQeHv6oima8HLlPwpDIPS2gxJk8BZrTZMfCA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-50P6YRMnZVItZUfB0+NzqMww2mmm4vB3zhTVtWUtGoXeiw78g1AEnVlmS28gYXPHM1P987jTvR7EON9u9ig/Dg=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-P0pnMHCmpkpcsxkWpilmeoD79LkbkoIcv6H0aeM9ArT/71/JBhvqH+HjMHSJCzni/9uR6er+nH5F+qol0UO6Bw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-vun54YA/UzprCu9p8BC4rMwFU5Cj9xrHAHYLYUp/yq4H0pfmBIiQM62nsfIKizRThe/TkBFy60EEi9myf6raYA=="],
"sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
"sst-linux-x64": ["sst-linux-x64@3.17.8", "", { "os": "linux", "cpu": "x64" }, "sha512-HqByCaLE2gEJbM20P1QRd+GqDMAiieuU53FaZA1F+AGxQi+kR82NWjrPqFcMj4dMYg8w/TWXuV+G5+PwoUmpDw=="],
"sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
"sst-linux-x86": ["sst-linux-x86@3.17.8", "", { "os": "linux", "cpu": "none" }, "sha512-bCd6QM3MejfSmdvg8I/k+aUJQIZEQJg023qmN78fv00vwlAtfECvY7tjT9E2m3LDp33pXrcRYbFOQzPu+tWFfA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-pilx0n8gm4aHJae/vNiqIwZkWF3tdwWzD/ON7hkytw+CVSZ0FXtyFW/yO/+2u3Yw0Kj0lSWPnUqYgm/eHPLwQA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
"sst-win32-x64": ["sst-win32-x64@3.17.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb0FVRyiOtESudF1V8ucW65PuHrx/iOHUamIO0JnbujWNHZBTRPB2QHN1dbewgkueYDaCmyS8lvuIImLwYJnzQ=="],
"sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
"sst-win32-x86": ["sst-win32-x86@3.17.8", "", { "os": "win32", "cpu": "none" }, "sha512-oVmFa/PoElQmfnGJlB0w6rPXiYuldiagO6AbrLMT/6oAnWerLQ8Uhv9tJWfMh3xtPLImQLTjxDo1v0AIzEv9QA=="],
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
@@ -1524,8 +1513,6 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
"tar-fs": ["tar-fs@3.0.9", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
@@ -1534,8 +1521,6 @@
"thread-stream": ["thread-stream@0.15.2", "", { "dependencies": { "real-require": "^0.1.0" } }, "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -1620,8 +1605,6 @@
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -1698,7 +1681,7 @@
"youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="],
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"zod": ["zod@3.25.49", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
"zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="],
@@ -1710,12 +1693,22 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q=="],
"@astrojs/sitemap/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"@aws-crypto/crc32/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@@ -1734,6 +1727,8 @@
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@modelcontextprotocol/sdk/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
"@openauthjs/openauth/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
@@ -1770,10 +1765,14 @@
"astro/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"astro/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"eventsource/eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"get-source/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1786,8 +1785,12 @@
"miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
"opencontrol/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"opencontrol/zod-to-json-schema": ["zod-to-json-schema@3.24.3", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
+5
View File
@@ -9,6 +9,9 @@ const bucket = new sst.cloudflare.Bucket("Bucket")
export const api = new sst.cloudflare.Worker("Api", {
domain: `api.${domain}`,
handler: "packages/function/src/api.ts",
environment: {
WEB_DOMAIN: domain,
},
url: true,
link: [bucket],
transform: {
@@ -36,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,
},
})
+6
View File
@@ -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": {
+7 -7
View File
@@ -5,7 +5,9 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"dev": "bun run packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"stainless": "./scripts/stainless",
"postinstall": "./scripts/hooks"
},
"workspaces": {
@@ -15,13 +17,13 @@
"catalog": {
"typescript": "5.8.2",
"@types/node": "22.13.9",
"zod": "3.24.2",
"ai": "4.3.16"
"zod": "3.25.49",
"ai": "5.0.0-beta.7"
}
},
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6"
"sst": "3.17.8"
},
"repository": {
"type": "git",
@@ -29,10 +31,8 @@
},
"license": "MIT",
"prettier": {
"semi": false
},
"overrides": {
"zod": "3.24.2"
"semi": false,
"printWidth": 120
},
"trustedDependencies": [
"esbuild",
+5 -9
View File
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"
type Env = {
SYNC_SERVER: DurableObjectNamespace<SyncServer>
Bucket: R2Bucket
WEB_DOMAIN: string
}
export class SyncServer extends DurableObject<Env> {
@@ -37,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
@@ -127,7 +125,7 @@ export default {
return new Response(
JSON.stringify({
secret,
url: "https://opencode.ai/s/" + short,
url: `https://${env.WEB_DOMAIN}/s/${short}`,
}),
{
headers: { "Content-Type": "application/json" },
@@ -183,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)
}
@@ -192,8 +189,7 @@ export default {
if (request.method === "GET" && method === "share_data") {
const id = url.searchParams.get("id")
console.log("share_data", id)
if (!id)
return new Response("Error: Share ID is required", { status: 400 })
if (!id) return new Response("Error: Share ID is required", { status: 400 })
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
const data = await stub.getData()
-1
View File
@@ -1,4 +1,3 @@
node_modules
research
dist
gen
+7
View File
@@ -297,6 +297,13 @@
},
"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": {
+11 -32
View File
@@ -40,7 +40,7 @@ for (const [os, arch] of targets) {
console.log(`building ${os}-${arch}`)
const name = `${pkg.name}-${os}-${arch}`
await $`mkdir -p dist/${name}/bin`
await $`GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
"../tui",
)
await $`bun build --define OPENCODE_VERSION="'${version}'" --compile --minify --target=bun-${os}-${arch} --outfile=dist/${name}/bin/opencode ./src/index.ts ./dist/${name}/bin/tui`
@@ -57,8 +57,7 @@ for (const [os, arch] of targets) {
2,
),
)
if (!dry)
await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
if (!dry) await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
optionalDependencies[name] = version
}
@@ -82,8 +81,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
2,
),
)
if (!dry)
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
if (!dry) await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
if (!snapshot) {
// Github Release
@@ -91,15 +89,11 @@ if (!snapshot) {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
}
const previous = await fetch(
"https://api.github.com/repos/sst/opencode/releases/latest",
)
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json())
.then((data) => data.tag_name)
const commits = await fetch(
`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`,
)
const commits = await fetch(`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`)
.then((res) => res.json())
.then((data) => data.commits || [])
@@ -117,26 +111,13 @@ if (!snapshot) {
})
.join("\n")
if (!dry)
await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
if (!dry) await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
// Calculate SHA values
const arm64Sha =
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const x64Sha =
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macX64Sha =
await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macArm64Sha =
await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
// AUR package
const pkgbuild = [
@@ -170,9 +151,7 @@ if (!snapshot) {
for (const pkg of ["opencode", "opencode-bin"]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(
pkgbuild.replace("${pkg}", pkg),
)
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild.replace("${pkg}", pkg))
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
+3 -12
View File
@@ -45,23 +45,14 @@ export namespace App {
}
export const provideExisting = ctx.provide
export async function provide<T>(
input: Input,
cb: (app: App.Info) => Promise<T>,
) {
export async function provide<T>(input: Input, cb: (app: App.Info) => Promise<T>) {
log.info("creating", {
cwd: input.cwd,
})
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) =>
x ? path.dirname(x) : undefined,
)
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) => (x ? path.dirname(x) : undefined))
log.info("git", { git })
const data = path.join(
Global.Path.data,
"project",
git ? directory(git) : "global",
)
const data = path.join(Global.Path.data, "project", git ? directory(git) : "global")
const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as {
initialized: number
+12 -21
View File
@@ -10,14 +10,8 @@ export namespace AuthAnthropic {
url.searchParams.set("code", "true")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("response_type", "code")
url.searchParams.set(
"redirect_uri",
"https://console.anthropic.com/oauth/code/callback",
)
url.searchParams.set(
"scope",
"org:create_api_key user:profile user:inference",
)
url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
url.searchParams.set("scope", "org:create_api_key user:profile user:inference")
url.searchParams.set("code_challenge", pkce.challenge)
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("state", pkce.verifier)
@@ -57,20 +51,17 @@ export namespace AuthAnthropic {
const info = await Auth.get("anthropic")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
const response = await fetch(
"https://console.anthropic.com/v1/oauth/token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: info.refresh,
client_id: CLIENT_ID,
}),
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
)
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: info.refresh,
client_id: CLIENT_ID,
}),
})
if (!response.ok) return
const json = await response.json()
await Auth.set("anthropic", {
+1 -3
View File
@@ -4,9 +4,7 @@ import path from "path"
export const AuthCopilot = lazy(async () => {
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
const response = fetch(
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
)
const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts")
.then((x) => Bun.write(file, x))
.catch(() => {})
+1 -4
View File
@@ -122,10 +122,7 @@ export namespace AuthGithubCopilot {
return tokenData.token
}
export const DeviceCodeError = NamedError.create(
"DeviceCodeError",
z.object({}),
)
export const DeviceCodeError = NamedError.create("DeviceCodeError", z.object({}))
export const TokenExchangeError = NamedError.create(
"TokenExchangeError",
+13 -8
View File
@@ -8,10 +8,7 @@ import { readableStreamToText } from "bun"
export namespace BunProc {
const log = Log.create({ service: "bun" })
export async function run(
cmd: string[],
options?: Bun.SpawnOptions.OptionsObject<any, any, any>,
) {
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
log.info("running", {
cmd: [which(), ...cmd],
...options,
@@ -26,9 +23,17 @@ export namespace BunProc {
BUN_BE_BUN: "1",
},
})
const code = await result.exited;
const stdout = result.stdout ? typeof result.stdout === "number" ? result.stdout : await readableStreamToText(result.stdout) : undefined
const stderr = result.stderr ? typeof result.stderr === "number" ? result.stderr : await readableStreamToText(result.stderr) : undefined
const code = await result.exited
const stdout = result.stdout
? typeof result.stdout === "number"
? result.stdout
: await readableStreamToText(result.stdout)
: undefined
const stderr = result.stderr
? typeof result.stderr === "number"
? result.stderr
: await readableStreamToText(result.stderr)
: undefined
log.info("done", {
code,
stdout,
@@ -61,7 +66,7 @@ export namespace BunProc {
if (parsed.dependencies[pkg] === version) return mod
parsed.dependencies[pkg] = version
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
await BunProc.run(["install", "--cwd", Global.Path.cache, "--registry=https://registry.npmjs.org"], {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
+2 -8
View File
@@ -18,10 +18,7 @@ export namespace Bus {
const registry = new Map<string, EventDefinition>()
export function event<Type extends string, Properties extends ZodType>(
type: Type,
properties: Properties,
) {
export function event<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
@@ -72,10 +69,7 @@ export namespace Bus {
export function subscribe<Definition extends EventDefinition>(
def: Definition,
callback: (event: {
type: Definition["type"]
properties: z.infer<Definition["properties"]>
}) => void,
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
) {
return raw(def.type, callback)
}
+1 -6
View File
@@ -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)
})
+6 -22
View File
@@ -15,11 +15,7 @@ export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
builder: (yargs) =>
yargs
.command(AuthLoginCommand)
.command(AuthLogoutCommand)
.command(AuthListCommand)
.demandCommand(),
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
async handler() {},
})
@@ -31,9 +27,7 @@ export const AuthListCommand = cmd({
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir)
? authPath.replace(homedir, "~")
: authPath
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await Auth.all().then((x) => Object.entries(x))
const database = await ModelsDev.get()
@@ -114,8 +108,7 @@ export const AuthLoginCommand = cmd({
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) =>
x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only",
validate: (x) => (x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
@@ -186,17 +179,13 @@ export const AuthLoginCommand = cmd({
await new Promise((resolve) => setTimeout(resolve, 10))
const deviceInfo = await copilot.authorize()
prompts.note(
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
)
prompts.note(`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`)
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
while (true) {
await new Promise((resolve) =>
setTimeout(resolve, deviceInfo.interval * 1000),
)
await new Promise((resolve) => setTimeout(resolve, deviceInfo.interval * 1000))
const response = await copilot.poll(deviceInfo.device)
if (response.status === "pending") continue
if (response.status === "success") {
@@ -248,12 +237,7 @@ export const AuthLogoutCommand = cmd({
const providerID = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label:
(database[key]?.name || key) +
UI.Style.TEXT_DIM +
" (" +
value.type +
")",
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
+1 -2
View File
@@ -31,7 +31,6 @@ const FileStatusCommand = cmd({
export const FileCommand = cmd({
command: "file",
builder: (yargs) =>
yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
builder: (yargs) => yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
async handler() {},
})
+1 -3
View File
@@ -17,9 +17,7 @@ export const DebugCommand = cmd({
command: "wait",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1_000 * 60 * 60 * 24),
)
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
})
},
})
+3 -6
View File
@@ -5,15 +5,13 @@ import { Log } from "../../../util/log"
export const LSPCommand = cmd({
command: "lsp",
builder: (yargs) =>
yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
builder: (yargs) => yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
async handler() {},
})
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
@@ -24,8 +22,7 @@ const DiagnosticsCommand = cmd({
export const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) =>
yargs.positional("query", { type: "string", demandOption: true }),
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
@@ -5,12 +5,7 @@ import { cmd } from "../cmd"
export const RipgrepCommand = cmd({
command: "rg",
builder: (yargs) =>
yargs
.command(TreeCommand)
.command(FilesCommand)
.command(SearchCommand)
.demandCommand(),
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
async handler() {},
})
@@ -4,11 +4,7 @@ import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
builder: (yargs) =>
yargs
.command(SnapshotCreateCommand)
.command(SnapshotRestoreCommand)
.demandCommand(),
builder: (yargs) => yargs.command(SnapshotCreateCommand).command(SnapshotRestoreCommand).demandCommand(),
async handler() {},
})
+1 -4
View File
@@ -10,9 +10,6 @@ export const GenerateCommand = {
const dir = "gen"
await fs.rmdir(dir, { recursive: true }).catch(() => {})
await fs.mkdir(dir, { recursive: true })
await Bun.write(
path.join(dir, "openapi.json"),
JSON.stringify(specs, null, 2),
)
await Bun.write(path.join(dir, "openapi.json"), JSON.stringify(specs, null, 2))
},
} satisfies CommandModule
+29 -30
View File
@@ -2,12 +2,12 @@ import type { Argv } from "yargs"
import { Bus } from "../../bus"
import { Provider } from "../../provider/provider"
import { Session } from "../../session"
import { Message } from "../../session/message"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
import { MessageV2 } from "../../session/message-v2"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -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()
})
+1 -3
View File
@@ -38,9 +38,7 @@ export const ServeCommand = cmd({
hostname,
})
console.log(
`opencode server listening on http://${server.hostname}:${server.port}`,
)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})
+51 -9
View File
@@ -9,15 +9,28 @@ import fs from "fs/promises"
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", {
type: "string",
describe: "path to start opencode in",
}),
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) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
@@ -28,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"
@@ -39,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
@@ -57,14 +69,22 @@ export const TuiCommand = cmd({
cwd = process.cwd()
cmd = [binary]
}
Log.Default.info("tui", {
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),
},
@@ -100,7 +120,7 @@ export const TuiCommand = cmd({
UI.empty()
UI.println(UI.logo(" "))
const result = await Bun.spawn({
cmd: [process.execPath, "auth", "login"],
cmd: [...getOpencodeCommand(), "auth", "login"],
cwd: process.cwd(),
stdout: "inherit",
stderr: "inherit",
@@ -112,3 +132,25 @@ export const TuiCommand = cmd({
}
},
})
/**
* Get the correct command to run opencode CLI
* In development: ["bun", "run", "packages/opencode/src/index.ts"]
* In production: ["/path/to/opencode"]
*/
function getOpencodeCommand(): string[] {
// Check if OPENCODE_BIN_PATH is set (used by shell wrapper scripts)
if (process.env["OPENCODE_BIN_PATH"]) {
return [process.env["OPENCODE_BIN_PATH"]]
}
const execPath = process.execPath.toLowerCase()
if (Installation.isDev()) {
// In development, use bun to run the TypeScript entry point
return [execPath, "run", process.argv[1]]
}
// In production, use the current executable path
return [process.execPath]
}
+9 -5
View File
@@ -27,22 +27,26 @@ export const UpgradeCommand = {
const detectedMethod = await Installation.method()
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
prompts.log.error(
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
)
prompts.log.error(`opencode is installed to ${process.execPath} and seems to be managed by a package manager`)
prompts.outro("Done")
return
}
prompts.log.info("Using method: " + method)
const target = args.target ?? (await Installation.latest())
if (Installation.VERSION === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
prompts.outro("Done")
return
}
prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed")
if (err instanceof Installation.UpgradeFailedError)
prompts.log.error(err.data.stderr)
if (err instanceof Installation.UpgradeFailedError) prompts.log.error(err.data.stderr)
else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")
return
+2 -5
View File
@@ -5,14 +5,11 @@ import { UI } from "./ui"
export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (Config.JsonError.isInstance(input))
return `Config file at ${input.data.path} is not valid JSON`
if (Config.JsonError.isInstance(input)) return `Config file at ${input.data.path} is not valid JSON`
if (Config.InvalidError.isInstance(input))
return [
`Config file at ${input.data.path} is invalid`,
...(input.data.issues?.map(
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []),
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
].join("\n")
if (UI.CancelledError.isInstance(input)) return ""
+31 -95
View File
@@ -29,18 +29,12 @@ export namespace Config {
export const McpLocal = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z
.string()
.array()
.describe("Command and arguments to run the MCP server"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
@@ -51,10 +45,7 @@ export namespace Config {
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
@@ -66,68 +57,38 @@ 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"),
leader: z.string().optional().describe("Leader key for keybind combinations"),
app_help: z.string().optional().describe("Show help dialog"),
editor_open: z.string().optional().describe("Open external editor"),
session_new: z.string().optional().describe("Create a new session"),
session_list: z.string().optional().describe("List all sessions"),
session_share: z.string().optional().describe("Share current session"),
session_interrupt: z
.string()
.optional()
.describe("Interrupt current session"),
session_compact: z
.string()
.optional()
.describe("Toggle compact mode for session"),
session_unshare: z.string().optional().describe("Unshare 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"),
file_list: z.string().optional().describe("List files"),
file_close: z.string().optional().describe("Close file"),
file_search: z.string().optional().describe("Search file"),
file_diff_toggle: z.string().optional().describe("Toggle split/unified diff"),
project_init: z.string().optional().describe("Initialize project configuration"),
input_clear: z.string().optional().describe("Clear input field"),
input_paste: z.string().optional().describe("Paste from clipboard"),
input_submit: z.string().optional().describe("Submit input"),
input_newline: z.string().optional().describe("Insert newline in input"),
history_previous: z
.string()
.optional()
.describe("Navigate to previous history item"),
history_next: z
.string()
.optional()
.describe("Navigate to next history item"),
messages_page_up: z
.string()
.optional()
.describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.describe("Scroll messages down by one page"),
messages_half_page_up: z
.string()
.optional()
.describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
.optional()
.describe("Scroll messages down by half page"),
messages_previous: z
.string()
.optional()
.describe("Navigate to previous message"),
messages_page_up: z.string().optional().describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().describe("Scroll messages down by one page"),
messages_half_page_up: z.string().optional().describe("Scroll messages up by half page"),
messages_half_page_down: z.string().optional().describe("Scroll messages down by half page"),
messages_previous: z.string().optional().describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
messages_first: z
.string()
.optional()
.describe("Navigate to first message"),
messages_first: z.string().optional().describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
messages_layout_toggle: z.string().optional().describe("Toggle layout"),
messages_copy: z.string().optional().describe("Copy message"),
messages_revert: z.string().optional().describe("Revert message"),
app_exit: z.string().optional().describe("Exit the application"),
})
.strict()
@@ -136,33 +97,13 @@ export namespace Config {
})
export const Info = z
.object({
$schema: z
.string()
.optional()
.describe("JSON schema reference for configuration validation"),
theme: z
.string()
.optional()
.describe("Theme name to use for the interface"),
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
autoshare: z
.boolean()
.optional()
.describe("Share newly created sessions automatically"),
autoupdate: z
.boolean()
.optional()
.describe("Automatically update to the latest version"),
disabled_providers: z
.array(z.string())
.optional()
.describe("Disable providers that are loaded automatically"),
model: z
.string()
.describe(
"Model to use in the format of provider/model, eg anthropic/claude-2",
)
.optional(),
autoshare: z.boolean().optional().describe("Share newly created sessions automatically"),
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
provider: z
.record(
ModelsDev.Provider.partial().extend({
@@ -172,10 +113,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"),
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
@@ -223,10 +162,7 @@ export namespace Config {
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await Bun.write(
path.join(Global.Path.config, "config.json"),
JSON.stringify(result, null, 2),
)
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})
+1 -3
View File
@@ -22,9 +22,7 @@ export namespace ConfigHooks {
command: item.command,
})
Bun.spawn({
cmd: item.command.map((x) =>
x.replace("$FILE", payload.properties.file),
),
cmd: item.command.map((x) => x.replace("$FILE", payload.properties.file)),
env: item.environment,
cwd: app.path.cwd,
stdout: "ignore",
+9 -18
View File
@@ -45,10 +45,7 @@ export namespace Fzf {
log.info("found", { filepath })
return { filepath }
}
filepath = path.join(
Global.Path.bin,
"fzf" + (process.platform === "win32" ? ".exe" : ""),
)
filepath = path.join(Global.Path.bin, "fzf" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath)
if (!(await file.exists())) {
@@ -56,18 +53,15 @@ export namespace Fzf {
const arch = archMap[process.arch as keyof typeof archMap] ?? "amd64"
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
if (!config) throw new UnsupportedPlatformError({ platform: process.platform })
const version = VERSION
const platformName =
process.platform === "win32" ? "windows" : process.platform
const platformName = process.platform === "win32" ? "windows" : process.platform
const filename = `fzf-${version}-${platformName}_${arch}.${config.extension}`
const url = `https://github.com/junegunn/fzf/releases/download/v${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
@@ -86,14 +80,11 @@ export namespace Fzf {
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
const proc = Bun.spawn(["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin], {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
+5 -23
View File
@@ -24,11 +24,7 @@ export namespace File {
const app = App.info()
if (!app.git) return []
const diffOutput = await $`git diff --numstat HEAD`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
const diffOutput = await $`git diff --numstat HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
const changedFiles = []
@@ -45,19 +41,13 @@ export namespace File {
}
}
const untrackedOutput = await $`git ls-files --others --exclude-standard`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
const untrackedOutput = await $`git ls-files --others --exclude-standard`.cwd(app.path.cwd).quiet().nothrow().text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Bun.file(
path.join(app.path.root, filepath),
).text()
const content = await Bun.file(path.join(app.path.root, filepath)).text()
const lines = content.split("\n").length
changedFiles.push({
file: filepath,
@@ -72,11 +62,7 @@ export namespace File {
}
// Get deleted files
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
@@ -112,11 +98,7 @@ export namespace File {
filepath: rel,
})
if (diff !== "unmodified") {
const original = await $`git show HEAD:${rel}`
.cwd(app.path.root)
.quiet()
.nothrow()
.text()
const original = await $`git show HEAD:${rel}`.cwd(app.path.root).quiet().nothrow().text()
const patch = createPatch(file, original, content, "old", "new", {
context: Infinity,
})
+13 -37
View File
@@ -122,15 +122,11 @@ export namespace Ripgrep {
const state = lazy(async () => {
let filepath = Bun.which("rg")
if (filepath) return { filepath }
filepath = path.join(
Global.Path.bin,
"rg" + (process.platform === "win32" ? ".exe" : ""),
)
filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath)
if (!(await file.exists())) {
const platformKey =
`${process.arch}-${process.platform}` as keyof typeof PLATFORM
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
@@ -139,8 +135,7 @@ export namespace Ripgrep {
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
@@ -164,14 +159,11 @@ export namespace Ripgrep {
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
const proc = Bun.spawn(["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin], {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
@@ -193,17 +185,11 @@ export namespace Ripgrep {
return filepath
}
export async function files(input: {
cwd: string
query?: string
glob?: string
limit?: number
}) {
export async function files(input: { cwd: string; query?: string; glob?: string; limit?: number }) {
const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
`${await filepath()} --files --follow --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
if (input.query)
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
@@ -310,18 +296,8 @@ export namespace Ripgrep {
return lines.join("\n")
}
export async function search(input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
}) {
const args = [
`${await filepath()}`,
"--json",
"--hidden",
"--glob='!.git/*'",
]
export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
if (input.glob) {
for (const g of input.glob) {
+4 -4
View File
@@ -1,6 +1,8 @@
import { App } from "../app/app"
import { Log } from "../util/log"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export const state = App.state("tool.filetimes", () => {
const read: {
[sessionID: string]: {
@@ -13,6 +15,7 @@ export namespace FileTime {
})
export function read(sessionID: string, file: string) {
log.info("read", { sessionID, file })
const { read } = state()
read[sessionID] = read[sessionID] || {}
read[sessionID][file] = new Date()
@@ -24,10 +27,7 @@ export namespace FileTime {
export async function assert(sessionID: string, filepath: string) {
const time = get(sessionID, filepath)
if (!time)
throw new Error(
`You must read the file ${filepath} before overwriting it. Use the Read tool first`,
)
if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`)
const stats = await Bun.file(filepath).stat()
if (stats.mtime.getTime() > time.getTime()) {
throw new Error(
+30 -30
View File
@@ -3,6 +3,7 @@ import { Bus } from "../bus"
import fs from "fs"
import { App } from "../app/app"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
@@ -16,37 +17,36 @@ export namespace FileWatcher {
}),
),
}
const state = App.state(
"file.watcher",
() => {
const app = App.use()
if (!app.info.git) return {}
try {
const watcher = fs.watch(app.info.path.cwd, { recursive: true }, (event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
})
})
return { watcher }
} catch {
return {}
}
},
async (state) => {
state.watcher?.close()
},
)
export function init() {
App.state(
"file.watcher",
() => {
const app = App.use()
try {
const watcher = fs.watch(
app.info.path.cwd,
{ recursive: true },
(event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
})
},
)
return { watcher }
} catch {
return {}
}
},
async (state) => {
state.watcher?.close()
},
)()
if (Flag.OPENCODE_DISABLE_WATCHER) return
state()
}
}
+1
View File
@@ -1,5 +1,6 @@
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_DISABLE_WATCHER = truthy("OPENCODE_DISABLE_WATCHER")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
+1 -15
View File
@@ -94,21 +94,7 @@ export const zig: Info = {
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [
".c",
".cc",
".cpp",
".cxx",
".c++",
".h",
".hh",
".hpp",
".hxx",
".h++",
".ino",
".C",
".H",
],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
return Bun.which("clang-format") !== null
},
+4 -17
View File
@@ -26,11 +26,7 @@ export namespace Identifier {
return generateID(prefix, true, given)
}
function generateID(
prefix: keyof typeof prefixes,
descending: boolean,
given?: string,
): string {
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
if (!given) {
return generateNewID(prefix, descending)
}
@@ -42,8 +38,7 @@ export namespace Identifier {
}
function randomBase62(length: number): string {
const chars =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
@@ -52,10 +47,7 @@ export namespace Identifier {
return result
}
function generateNewID(
prefix: keyof typeof prefixes,
descending: boolean,
): string {
function generateNewID(prefix: keyof typeof prefixes, descending: boolean): string {
const currentTimestamp = Date.now()
if (currentTimestamp !== lastTimestamp) {
@@ -73,11 +65,6 @@ export namespace Identifier {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return (
prefixes[prefix] +
"_" +
timeBytes.toString("hex") +
randomBase62(LENGTH - 12)
)
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
}
+2 -8
View File
@@ -55,10 +55,7 @@ const cli = yargs(hideBin(process.argv))
.command(ServeCommand)
.command(ModelsCommand)
.fail((msg) => {
if (
msg.startsWith("Unknown argument") ||
msg.startsWith("Not enough non-option arguments")
) {
if (msg.startsWith("Unknown argument") || msg.startsWith("Not enough non-option arguments")) {
cli.showHelp("log")
}
})
@@ -97,10 +94,7 @@ try {
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (formatted === undefined)
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
if (formatted === undefined) UI.error("Unexpected error, check log file at " + Log.file() + " for more details")
process.exitCode = 1
}
+1 -2
View File
@@ -135,8 +135,7 @@ export namespace Installation {
})
}
export const VERSION =
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export async function latest() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
+17 -19
View File
@@ -1,9 +1,5 @@
import path from "path"
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import { App } from "../app/app"
import { Log } from "../util/log"
@@ -66,6 +62,7 @@ export namespace LSPClient {
log.info("sending initialize", { id: serverID })
await withTimeout(
connection.sendRequest("initialize", {
rootUri: "file://" + app.path.cwd,
processId: server.process.pid,
workspaceFolders: [
{
@@ -92,11 +89,20 @@ export namespace LSPClient {
},
}),
5_000,
).catch(() => {
throw new InitializeError({ serverID })
).catch((err) => {
log.error("initialize error", { error: err })
throw new InitializeError(
{ serverID },
{
cause: err,
},
)
})
await connection.sendNotification("initialized", {})
log.info("initialized")
log.info("initialized", {
serverID,
})
const files: {
[path: string]: number
@@ -111,9 +117,7 @@ export namespace LSPClient {
},
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path)
? input.path
: path.resolve(app.path.cwd, input.path)
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
const file = Bun.file(input.path)
const text = await file.text()
const version = files[input.path]
@@ -145,18 +149,13 @@ export namespace LSPClient {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
input.path = path.isAbsolute(input.path)
? input.path
: path.resolve(app.path.cwd, input.path)
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
log.info("waiting for diagnostics", input)
let unsub: () => void
return await withTimeout(
new Promise<void>((resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.serverID
) {
if (event.properties.path === input.path && event.properties.serverID === result.serverID) {
log.info("got diagnostics", input)
unsub?.()
resolve()
@@ -174,7 +173,6 @@ export namespace LSPClient {
log.info("shutting down", { serverID })
connection.end()
connection.dispose()
server.process.kill("SIGTERM")
log.info("shutdown", { serverID })
},
}
+4 -14
View File
@@ -46,9 +46,7 @@ export namespace LSP {
if (!file) continue
const handle = await server.spawn(App.info())
if (!handle) break
const client = await LSPClient.create(server.id, handle).catch(
() => {},
)
const client = await LSPClient.create(server.id, handle).catch((err) => log.error("", { error: err }))
if (!client) break
clients.set(server.id, client)
break
@@ -77,9 +75,7 @@ export namespace LSP {
.map((x) => x.id)
await run(async (client) => {
if (!matches.includes(client.serverID)) return
const wait = waitForDiagnostics
? client.waitForDiagnostics({ path: input })
: Promise.resolve()
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
return wait
})
@@ -97,11 +93,7 @@ export namespace LSP {
return results
}
export async function hover(input: {
file: string
line: number
character: number
}) {
export async function hover(input: { file: string; line: number; character: number }) {
return run((client) => {
return client.connection.sendRequest("textDocument/hover", {
textDocument: {
@@ -123,9 +115,7 @@ export namespace LSP {
).then((result) => result.flat() as LSP.Symbol[])
}
async function run<T>(
input: (client: LSPClient.Info) => Promise<T>,
): Promise<T[]> {
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
const clients = await state().then((x) => [...x.clients.values()])
const tasks = clients.map((x) => input(x))
return Promise.all(tasks)
+68 -30
View File
@@ -4,6 +4,8 @@ import path from "path"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -23,21 +25,14 @@ export namespace LSPServer {
id: "typescript",
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(app) {
const tsserver = await Bun.resolve(
"typescript/lib/tsserver.js",
app.path.cwd,
).catch(() => {})
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
if (!tsserver) return
const proc = spawn(
BunProc.which(),
["x", "typescript-language-server", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
env: {
...process.env,
BUN_BE_BUN: "1",
},
)
})
return {
process: proc,
initialization: {
@@ -57,6 +52,7 @@ export namespace LSPServer {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("go")) return
log.info("installing gopls")
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
@@ -70,10 +66,7 @@ export namespace LSPServer {
log.error("Failed to install gopls")
return
}
bin = path.join(
Global.Path.bin,
"gopls" + (process.platform === "win32" ? ".exe" : ""),
)
bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed gopls`, {
bin,
})
@@ -110,10 +103,7 @@ export namespace LSPServer {
log.error("Failed to install ruby-lsp")
return
}
bin = path.join(
Global.Path.bin,
"ruby-lsp" + (process.platform === "win32" ? ".exe" : ""),
)
bin = path.join(Global.Path.bin, "ruby-lsp" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed ruby-lsp`, {
bin,
})
@@ -128,19 +118,67 @@ export namespace LSPServer {
id: "pyright",
extensions: [".py", ".pyi"],
async spawn() {
const proc = spawn(
BunProc.which(),
["x", "pyright-langserver", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
env: {
...process.env,
BUN_BE_BUN: "1",
},
)
})
return {
process: proc,
}
},
}
export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
async spawn() {
let binary = Bun.which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
binary = path.join(
Global.Path.bin,
"elixir-ls-master",
"release",
process.platform === "win32" ? "language_server.bar" : "language_server.sh",
)
if (!(await Bun.file(binary).exists())) {
const elixir = Bun.which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
return
}
log.info("downloading elixir-ls from GitHub releases")
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
await Bun.file(zipPath).write(response)
await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
await fs.rm(zipPath, {
force: true,
recursive: true,
})
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
.quiet()
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
.env({ MIX_ENV: "prod", ...process.env })
log.info(`installed elixir-ls`, {
path: elixirLsPath,
})
}
}
return {
process: spawn(binary),
}
},
}
}
+31 -59
View File
@@ -91,8 +91,7 @@ export namespace Provider {
if (!info || info.type !== "oauth") return
if (!info.access || info.expires < Date.now()) {
const tokens = await copilot.access(info.refresh)
if (!tokens)
throw new Error("GitHub Copilot authentication expired")
if (!tokens) throw new Error("GitHub Copilot authentication expired")
await Auth.set("github-copilot", {
type: "oauth",
...tokens,
@@ -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[],
],
)
}
+9 -10
View File
@@ -1,22 +1,21 @@
import type { LanguageModelV1Prompt } from "ai"
import type { ModelMessage } from "ai"
import { unique } from "remeda"
export namespace ProviderTransform {
export function message(
msgs: LanguageModelV1Prompt,
providerID: string,
modelID: string,
) {
export function message(msgs: ModelMessage[], providerID: string, modelID: string) {
if (providerID === "anthropic" || modelID.includes("anthropic")) {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerMetadata = {
...msg.providerMetadata,
msg.providerOptions = {
...msg.providerOptions,
anthropic: {
cacheControl: { type: "ephemeral" },
},
openaiCompatible: {
cache_control: { type: "ephemeral" },
},
}
}
}
@@ -25,8 +24,8 @@ export namespace ProviderTransform {
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerMetadata = {
...msg.providerMetadata,
msg.providerOptions = {
...msg.providerOptions,
bedrock: {
cachePoint: { type: "ephemeral" },
},
+9 -17
View File
@@ -6,7 +6,6 @@ import { streamSSE } from "hono/streaming"
import { Session } from "../session"
import { resolver, validator as zValidator } from "hono-openapi/zod"
import { z } from "zod"
import { Message } from "../session/message"
import { Provider } from "../provider/provider"
import { App } from "../app/app"
import { mapValues } from "remeda"
@@ -16,6 +15,7 @@ import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
import { MessageV2 } from "../session/message-v2"
const ERRORS = {
400: {
@@ -51,12 +51,9 @@ export namespace Server {
status: 400,
})
}
return c.json(
new NamedError.Unknown({ message: err.toString() }).toObject(),
{
status: 400,
},
)
return c.json(new NamedError.Unknown({ message: err.toString() }).toObject(), {
status: 400,
})
})
.use(async (c, next) => {
log.info("request", {
@@ -407,7 +404,7 @@ export namespace Server {
description: "List of messages",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
schema: resolver(MessageV2.Info.array()),
},
},
},
@@ -433,7 +430,7 @@ export namespace Server {
description: "Created message",
content: {
"application/json": {
schema: resolver(Message.Info),
schema: resolver(MessageV2.Assistant),
},
},
},
@@ -450,7 +447,7 @@ export namespace Server {
z.object({
providerID: z.string(),
modelID: z.string(),
parts: Message.MessagePart.array(),
parts: MessageV2.UserPart.array(),
}),
),
async (c) => {
@@ -481,15 +478,10 @@ export namespace Server {
},
}),
async (c) => {
const providers = await Provider.list().then((x) =>
mapValues(x, (item) => item.info),
)
const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
return c.json({
providers: Object.values(providers),
default: mapValues(
providers,
(item) => Provider.sort(Object.values(item.models))[0].id,
),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
})
},
)
File diff suppressed because it is too large Load Diff
+404
View 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)
}
}
+5 -42
View File
@@ -1,13 +1,9 @@
import z from "zod"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
export namespace Message {
export const OutputLengthError = NamedError.create(
"MessageOutputLengthError",
z.object({}),
)
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const ToolCall = z
.object({
@@ -49,11 +45,9 @@ export namespace Message {
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocation = z
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
.openapi({
ref: "ToolInvocation",
})
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).openapi({
ref: "ToolInvocation",
})
export type ToolInvocation = z.infer<typeof ToolInvocation>
export const TextPart = z
@@ -122,14 +116,7 @@ export namespace Message {
export type StepStartPart = z.infer<typeof StepStartPart>
export const MessagePart = z
.discriminatedUnion("type", [
TextPart,
ReasoningPart,
ToolInvocationPart,
SourceUrlPart,
FilePart,
StepStartPart,
])
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
.openapi({
ref: "MessagePart",
})
@@ -197,28 +184,4 @@ export namespace Message {
ref: "Message",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
"message.updated",
z.object({
info: Info,
}),
),
Removed: Bus.event(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
}),
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({
part: MessagePart,
sessionID: z.string(),
messageID: z.string(),
}),
),
}
}
@@ -134,7 +134,7 @@ The user will primarily request you perform software engineering tasks. This inc
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
+15
View File
@@ -2,6 +2,7 @@ import { App } from "../app/app"
import { Ripgrep } from "../file/ripgrep"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import path from "path"
import os from "os"
@@ -55,8 +56,10 @@ export namespace SystemPrompt {
"CLAUDE.md",
"CONTEXT.md", // deprecated
]
export async function custom() {
const { cwd, root } = App.info().path
const config = await Config.get()
const found = []
for (const item of CUSTOM_FILES) {
const matches = await Filesystem.findUp(item, cwd, root)
@@ -72,6 +75,18 @@ export namespace SystemPrompt {
.text()
.catch(() => ""),
)
if (config.instructions) {
for (const instruction of config.instructions) {
try {
const matches = await Filesystem.globUp(instruction, cwd, root)
found.push(...matches.map((x) => Bun.file(x).text()))
} catch {
continue // Skip invalid glob patterns
}
}
}
return Promise.all(found).then((result) => result.filter(Boolean))
}
+3 -5
View File
@@ -53,9 +53,7 @@ export namespace Share {
export const URL =
process.env["OPENCODE_API"] ??
(Installation.isSnapshot() || Installation.isDev()
? "https://api.dev.opencode.ai"
: "https://api.opencode.ai")
(Installation.isSnapshot() || Installation.isDev() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
export async function create(sessionID: string) {
return fetch(`${URL}/share_create`, {
@@ -66,10 +64,10 @@ export namespace Share {
.then((x) => x as { url: string; secret: string })
}
export async function remove(id: string) {
export async function remove(sessionID: string, secret: string) {
return fetch(`${URL}/share_delete`, {
method: "POST",
body: JSON.stringify({ id }),
body: JSON.stringify({ sessionID, secret }),
}).then((x) => x.json())
}
}
+41 -61
View File
@@ -1,14 +1,7 @@
import { App } from "../app/app"
import {
add,
commit,
init,
checkout,
statusMatrix,
remove,
} from "isomorphic-git"
import { $ } from "bun"
import path from "path"
import fs from "fs"
import fs from "fs/promises"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
@@ -16,66 +9,53 @@ export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
return
log.info("creating snapshot")
const app = App.info()
const git = gitdir(sessionID)
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: app.git ? undefined : 1000,
})
// not a git repo and too big to snapshot
if (!app.git && files.length === 1000) return
await init({
dir: app.path.cwd,
gitdir: git,
fs,
})
const status = await statusMatrix({
fs,
gitdir: git,
dir: app.path.cwd,
})
await add({
fs,
gitdir: git,
parallel: true,
dir: app.path.cwd,
filepath: files,
})
for (const [file, _head, workdir, stage] of status) {
if (workdir === 0 && stage === 1) {
log.info("remove", { file })
await remove({
fs,
gitdir: git,
dir: app.path.cwd,
filepath: file,
})
}
// not a git repo, check if too big to snapshot
if (!app.git) {
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: 1000,
})
log.info("found files", { count: files.length })
if (files.length > 1000) return
}
const result = await commit({
fs,
gitdir: git,
dir: app.path.cwd,
message: "snapshot",
author: {
name: "opencode",
email: "mail@opencode.ai",
},
})
log.info("commit", { result })
return result
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: app.path.root,
})
.quiet()
.nothrow()
log.info("initialized")
}
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
log.info("added files")
const result =
await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode <mail@opencode.ai>"`
.quiet()
.cwd(app.path.cwd)
.nothrow()
log.info("commit")
const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/)
if (!match) return
return match![1]
}
export async function restore(sessionID: string, commit: string) {
log.info("restore", { commit })
const app = App.info()
await checkout({
fs,
gitdir: gitdir(sessionID),
dir: app.path.cwd,
ref: commit,
force: true,
})
const git = gitdir(sessionID)
await $`git --git-dir=${git} checkout ${commit} --force`.quiet().cwd(app.path.root)
}
function gitdir(sessionID: string) {
+48 -12
View File
@@ -4,44 +4,79 @@ 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 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 +84,10 @@ export namespace Storage {
const glob = new Bun.Glob("**/*")
export async function* list(prefix: string) {
const dir = await state().then((x) => x.dir)
try {
for await (const item of glob.scan({
cwd: path.join(state().dir, prefix),
cwd: path.join(dir, prefix),
onlyFiles: true,
})) {
const result = path.join(prefix, item.slice(0, -5))
+3 -36
View File
@@ -4,25 +4,6 @@ import DESCRIPTION from "./bash.txt"
import { App } from "../app/app"
const MAX_OUTPUT_LENGTH = 30000
const BANNED_COMMANDS = [
"alias",
"curl",
"curlie",
"wget",
"axel",
"aria2c",
"nc",
"telnet",
"lynx",
"w3m",
"links",
"httpie",
"xh",
"http-prompt",
"chrome",
"firefox",
"safari",
]
const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
@@ -31,12 +12,7 @@ export const BashTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z
.number()
.min(0)
.max(MAX_TIMEOUT)
.describe("Optional timeout in milliseconds")
.optional(),
timeout: z.number().min(0).max(MAX_TIMEOUT).describe("Optional timeout in milliseconds").optional(),
description: z
.string()
.describe(
@@ -45,8 +21,6 @@ export const BashTool = Tool.define({
}),
async execute(params, ctx) {
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
if (BANNED_COMMANDS.some((item) => params.command.startsWith(item)))
throw new Error(`Command '${params.command}' is not allowed`)
const process = Bun.spawn({
cmd: ["bash", "-c", params.command],
@@ -62,21 +36,14 @@ export const BashTool = Tool.define({
const stderr = await new Response(process.stderr).text()
return {
title: params.command,
metadata: {
stderr,
stdout,
exit: process.exitCode,
description: params.description,
title: params.command,
},
output: [
`<stdout>`,
stdout ?? "",
`</stdout>`,
`<stderr>`,
stderr ?? "",
`</stderr>`,
].join("\n"),
output: [`<stdout>`, stdout ?? "", `</stdout>`, `<stderr>`, stderr ?? "", `</stderr>`].join("\n"),
}
},
})
+18 -57
View File
@@ -20,15 +20,8 @@ export const EditTool = Tool.define({
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z
.string()
.describe(
"The text to replace it with (must be different from old_string)",
),
replaceAll: z
.boolean()
.optional()
.describe("Replace all occurrences of old_string (default false)"),
newString: z.string().describe("The text to replace it with (must be different from old_string)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of old_string (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
@@ -40,9 +33,7 @@ export const EditTool = Tool.define({
}
const app = App.info()
const filepath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(app.path.cwd, params.filePath)
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
await Permission.ask({
id: "edit",
@@ -70,17 +61,11 @@ export const EditTool = Tool.define({
const file = Bun.file(filepath)
const stats = await file.stat().catch(() => {})
if (!stats) throw new Error(`File ${filepath} not found`)
if (stats.isDirectory())
throw new Error(`Path is a directory, not a file: ${filepath}`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`)
await FileTime.assert(ctx.sessionID, filepath)
contentOld = await file.text()
contentNew = replace(
contentOld,
params.oldString,
params.newString,
params.replaceAll,
)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
file: filepath,
@@ -88,9 +73,7 @@ export const EditTool = Tool.define({
contentNew = await file.text()
})()
const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
FileTime.read(ctx.sessionID, filepath)
@@ -110,17 +93,14 @@ export const EditTool = Tool.define({
metadata: {
diagnostics,
diff,
title: `${path.relative(app.path.root, filepath)}`,
},
title: `${path.relative(app.path.root, filepath)}`,
output,
}
},
})
export type Replacer = (
content: string,
find: string,
) => Generator<string, void, unknown>
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
@@ -208,10 +188,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (
content,
find,
) {
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(find)
@@ -229,9 +206,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+")
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
@@ -270,9 +245,7 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
}),
)
return lines
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
.join("\n")
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
}
const normalizedFind = removeIndentation(find)
@@ -423,10 +396,7 @@ export const ContextAwareReplacer: Replacer = function* (content, find) {
}
}
if (
totalNonEmptyLines === 0 ||
matchingLines / totalNonEmptyLines >= 0.5
) {
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
yield block
break // Only match the first occurrence
}
@@ -473,12 +443,7 @@ function trimDiff(diff: string): string {
return trimmedLines.join("\n")
}
export function replace(
content: string,
oldString: string,
newString: string,
replaceAll = false,
): string {
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
if (oldString === newString) {
throw new Error("oldString and newString must be different")
}
@@ -489,10 +454,10 @@ export function replace(
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
// EscapeNormalizedReplacer,
// TrimmedBoundaryReplacer,
// ContextAwareReplacer,
// MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
@@ -502,11 +467,7 @@ export function replace(
}
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue
return (
content.substring(0, index) +
newString +
content.substring(index + search.length)
)
return content.substring(0, index) + newString + content.substring(index + search.length)
}
}
throw new Error("oldString not found in content or was found multiple times")
+3 -7
View File
@@ -20,9 +20,7 @@ export const GlobTool = Tool.define({
async execute(params) {
const app = App.info()
let search = params.path ?? app.path.cwd
search = path.isAbsolute(search)
? search
: path.resolve(app.path.cwd, search)
search = path.isAbsolute(search) ? search : path.resolve(app.path.cwd, search)
const limit = 100
const files = []
@@ -53,17 +51,15 @@ export const GlobTool = Tool.define({
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
)
output.push("(Results are truncated. Consider using a more specific path or pattern.)")
}
}
return {
title: path.relative(app.path.root, search),
metadata: {
count: files.length,
truncated,
title: path.relative(app.path.root, search),
},
output: output.join("\n"),
}
+9 -21
View File
@@ -9,21 +9,9 @@ export const GrepTool = Tool.define({
id: "grep",
description: DESCRIPTION,
parameters: z.object({
pattern: z
.string()
.describe("The regex pattern to search for in file contents"),
path: z
.string()
.optional()
.describe(
"The directory to search in. Defaults to the current working directory.",
),
include: z
.string()
.optional()
.describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
),
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
async execute(params) {
if (!params.pattern) {
@@ -51,7 +39,8 @@ export const GrepTool = Tool.define({
if (exitCode === 1) {
return {
metadata: { matches: 0, truncated: false, title: params.pattern },
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
@@ -93,7 +82,8 @@ export const GrepTool = Tool.define({
if (finalMatches.length === 0) {
return {
metadata: { matches: 0, truncated: false, title: params.pattern },
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
@@ -114,16 +104,14 @@ export const GrepTool = Tool.define({
if (truncated) {
outputLines.push("")
outputLines.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
)
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
}
return {
title: params.pattern,
metadata: {
matches: finalMatches.length,
truncated,
title: params.pattern,
},
output: outputLines.join("\n"),
}
+4 -13
View File
@@ -24,16 +24,8 @@ export const ListTool = Tool.define({
id: "list",
description: DESCRIPTION,
parameters: z.object({
path: z
.string()
.describe(
"The absolute path to the directory to list (must be absolute, not relative)",
)
.optional(),
ignore: z
.array(z.string())
.describe("List of glob patterns to ignore")
.optional(),
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params) {
const app = App.info()
@@ -44,8 +36,7 @@ export const ListTool = Tool.define({
for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
if (IGNORE_PATTERNS.some((p) => file.includes(p))) continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file))) continue
files.push(file)
if (files.length >= LIMIT) break
}
@@ -99,10 +90,10 @@ export const ListTool = Tool.define({
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(app.path.root, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
title: path.relative(app.path.root, searchPath),
},
output,
}
@@ -13,20 +13,16 @@ export const LspDiagnosticTool = Tool.define({
}),
execute: async (args) => {
const app = App.info()
const normalized = path.isAbsolute(args.path)
? args.path
: path.join(app.path.cwd, args.path)
const normalized = path.isAbsolute(args.path) ? args.path : path.join(app.path.cwd, args.path)
await LSP.touchFile(normalized, true)
const diagnostics = await LSP.diagnostics()
const file = diagnostics[normalized]
return {
title: path.relative(app.path.root, normalized),
metadata: {
diagnostics,
title: path.relative(app.path.root, normalized),
},
output: file?.length
? file.map(LSP.Diagnostic.pretty).join("\n")
: "No errors found",
output: file?.length ? file.map(LSP.Diagnostic.pretty).join("\n") : "No errors found",
}
},
})
+2 -9
View File
@@ -15,9 +15,7 @@ export const LspHoverTool = Tool.define({
}),
execute: async (args) => {
const app = App.info()
const file = path.isAbsolute(args.file)
? args.file
: path.join(app.path.cwd, args.file)
const file = path.isAbsolute(args.file) ? args.file : path.join(app.path.cwd, args.file)
await LSP.touchFile(file, true)
const result = await LSP.hover({
...args,
@@ -25,14 +23,9 @@ export const LspHoverTool = Tool.define({
})
return {
title: path.relative(app.path.root, file) + ":" + args.line + ":" + args.character,
metadata: {
result,
title:
path.relative(app.path.root, file) +
":" +
args.line +
":" +
args.character,
},
output: JSON.stringify(result, null, 2),
}
+2 -4
View File
@@ -10,9 +10,7 @@ export const MultiEditTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(EditTool.parameters)
.describe("Array of edit operations to perform sequentially on the file"),
edits: z.array(EditTool.parameters).describe("Array of edit operations to perform sequentially on the file"),
}),
async execute(params, ctx) {
const results = []
@@ -30,9 +28,9 @@ export const MultiEditTool = Tool.define({
}
const app = App.info()
return {
title: path.relative(app.path.root, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
title: path.relative(app.path.root, params.filePath),
},
output: results.at(-1)!.output,
}
+10 -37
View File
@@ -6,9 +6,7 @@ import { FileTime } from "../file/time"
import DESCRIPTION from "./patch.txt"
const PatchParams = z.object({
patchText: z
.string()
.describe("The full patch text that describes all changes to be made"),
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
interface Change {
@@ -42,10 +40,7 @@ function identifyFilesNeeded(patchText: string): string[] {
const files: string[] = []
const lines = patchText.split("\n")
for (const line of lines) {
if (
line.startsWith("*** Update File:") ||
line.startsWith("*** Delete File:")
) {
if (line.startsWith("*** Update File:") || line.startsWith("*** Delete File:")) {
const filePath = line.split(":", 2)[1]?.trim()
if (filePath) files.push(filePath)
}
@@ -65,10 +60,7 @@ function identifyFilesAdded(patchText: string): string[] {
return files
}
function textToPatch(
patchText: string,
_currentFiles: Record<string, string>,
): [PatchOperation[], number] {
function textToPatch(patchText: string, _currentFiles: Record<string, string>): [PatchOperation[], number] {
const operations: PatchOperation[] = []
const lines = patchText.split("\n")
let i = 0
@@ -93,11 +85,7 @@ function textToPatch(
const changes: PatchChange[] = []
i++
while (
i < lines.length &&
!lines[i].startsWith("@@") &&
!lines[i].startsWith("***")
) {
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
const changeLine = lines[i]
if (changeLine.startsWith(" ")) {
changes.push({ type: "keep", content: changeLine.substring(1) })
@@ -151,10 +139,7 @@ function textToPatch(
return [operations, fuzz]
}
function patchToCommit(
operations: PatchOperation[],
currentFiles: Record<string, string>,
): Commit {
function patchToCommit(operations: PatchOperation[], currentFiles: Record<string, string>): Commit {
const changes: Record<string, Change> = {}
for (const op of operations) {
@@ -173,9 +158,7 @@ function patchToCommit(
const lines = originalContent.split("\n")
for (const hunk of op.hunks) {
const contextIndex = lines.findIndex((line) =>
line.includes(hunk.contextLine),
)
const contextIndex = lines.findIndex((line) => line.includes(hunk.contextLine))
if (contextIndex === -1) {
throw new Error(`Context line not found: ${hunk.contextLine}`)
}
@@ -204,11 +187,7 @@ function patchToCommit(
return { changes }
}
function generateDiff(
oldContent: string,
newContent: string,
filePath: string,
): [string, number, number] {
function generateDiff(oldContent: string, newContent: string, filePath: string): [string, number, number] {
// Mock implementation - would need actual diff generation
const lines1 = oldContent.split("\n")
const lines2 = newContent.split("\n")
@@ -296,9 +275,7 @@ export const PatchTool = Tool.define({
// Process the patch
const [patch, fuzz] = textToPatch(params.patchText, currentFiles)
if (fuzz > 3) {
throw new Error(
`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
)
throw new Error(`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`)
}
// Convert patch to commit
@@ -343,11 +320,7 @@ export const PatchTool = Tool.define({
const newContent = change.new_content || ""
// Calculate diff statistics
const [, additions, removals] = generateDiff(
oldContent,
newContent,
filePath,
)
const [, additions, removals] = generateDiff(oldContent, newContent, filePath)
totalAdditions += additions
totalRemovals += removals
@@ -358,11 +331,11 @@ export const PatchTool = Tool.define({
const output = result
return {
title: `${filesToRead.length} files`,
metadata: {
changed: changedFiles,
additions: totalAdditions,
removals: totalRemovals,
title: `${filesToRead.length} files`,
},
output,
}
+9 -27
View File
@@ -16,14 +16,8 @@ export const ReadTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The path to the file to read"),
offset: z
.number()
.describe("The line number to start reading from (0-based)")
.optional(),
limit: z
.number()
.describe("The number of lines to read (defaults to 2000)")
.optional(),
offset: z.number().describe("The line number to start reading from (0-based)").optional(),
limit: z.number().describe("The number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
let filePath = params.filePath
@@ -40,16 +34,13 @@ export const ReadTool = Tool.define({
const suggestions = dirEntries
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) ||
base.toLowerCase().includes(entry.toLowerCase()),
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
)
.map((entry) => path.join(dir, entry))
.slice(0, 3)
if (suggestions.length > 0) {
throw new Error(
`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
)
throw new Error(`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
}
throw new Error(`File not found: ${filePath}`)
@@ -57,21 +48,14 @@ export const ReadTool = Tool.define({
const stats = await file.stat()
if (stats.size > MAX_READ_SIZE)
throw new Error(
`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`,
)
throw new Error(`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`)
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset || 0
const isImage = isImageFile(filePath)
if (isImage)
throw new Error(
`This is an image file of type: ${isImage}\nUse a different tool to process images`,
)
if (isImage) throw new Error(`This is an image file of type: ${isImage}\nUse a different tool to process images`)
const lines = await file.text().then((text) => text.split("\n"))
const raw = lines.slice(offset, offset + limit).map((line) => {
return line.length > MAX_LINE_LENGTH
? line.substring(0, MAX_LINE_LENGTH) + "..."
: line
return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
})
const content = raw.map((line, index) => {
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
@@ -82,9 +66,7 @@ export const ReadTool = Tool.define({
output += content.join("\n")
if (lines.length > offset + content.length) {
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${
offset + content.length
})`
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${offset + content.length})`
}
output += "\n</file>"
@@ -93,10 +75,10 @@ export const ReadTool = Tool.define({
FileTime.read(ctx.sessionID, filePath)
return {
title: path.relative(App.info().path.root, filePath),
output,
metadata: {
preview,
title: path.relative(App.info().path.root, filePath),
},
}
},
+1 -1
View File
@@ -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
+14 -19
View File
@@ -3,41 +3,36 @@ import DESCRIPTION from "./task.txt"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
import { Message } from "../session/message"
import { MessageV2 } from "../session/message-v2"
export const TaskTool = Tool.define({
id: "task",
description: DESCRIPTION,
parameters: z.object({
description: z
.string()
.describe("A short (3-5 words) description of the task"),
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
}),
async execute(params, ctx) {
const session = await Session.create(ctx.sessionID)
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
const metadata = msg.metadata.assistant!
const msg = (await Session.getMessage(ctx.sessionID, ctx.messageID)) as MessageV2.Assistant
function summary(input: Message.Info) {
function summary(input: MessageV2.Info) {
const result = []
for (const part of input.parts) {
if (part.type === "tool-invocation") {
result.push({
toolInvocation: part.toolInvocation,
metadata: input.metadata.tool[part.toolInvocation.toolCallId],
})
if (part.type === "tool" && part.state.status === "completed") {
result.push(part)
}
}
return result
}
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
if (evt.properties.info.metadata.sessionID !== session.id) return
const unsub = Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
if (evt.properties.info.sessionID !== session.id) return
ctx.metadata({
title: params.description,
summary: summary(evt.properties.info),
metadata: {
summary: summary(evt.properties.info),
},
})
})
@@ -46,8 +41,8 @@ export const TaskTool = Tool.define({
})
const result = await Session.chat({
sessionID: session.id,
modelID: metadata.modelID,
providerID: metadata.providerID,
modelID: msg.modelID,
providerID: msg.providerID,
parts: [
{
type: "text",
@@ -57,8 +52,8 @@ export const TaskTool = Tool.define({
})
unsub()
return {
title: params.description,
metadata: {
title: params.description,
summary: summary(result),
},
output: result.parts.findLast((x) => x.type === "text")!.text,
+4 -8
View File
@@ -5,12 +5,8 @@ import { App } from "../app/app"
const TodoInfo = z.object({
content: z.string().min(1).describe("Brief description of the task"),
status: z
.enum(["pending", "in_progress", "completed"])
.describe("Current status of the task"),
priority: z
.enum(["high", "medium", "low"])
.describe("Priority level of the task"),
status: z.enum(["pending", "in_progress", "completed"]).describe("Current status of the task"),
priority: z.enum(["high", "medium", "low"]).describe("Priority level of the task"),
id: z.string().describe("Unique identifier for the todo item"),
})
type TodoInfo = z.infer<typeof TodoInfo>
@@ -32,9 +28,9 @@ export const TodoWriteTool = Tool.define({
const todos = state()
todos[opts.sessionID] = params.todos
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
todos: params.todos,
},
}
@@ -48,9 +44,9 @@ export const TodoReadTool = Tool.define({
async execute(_params, opts) {
const todos = state()[opts.sessionID] ?? []
return {
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
metadata: {
todos,
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
},
output: JSON.stringify(todos, null, 2),
}
+6 -10
View File
@@ -2,19 +2,15 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"
export namespace Tool {
interface Metadata {
title: string
[key: string]: any
}
export type Context<M extends Metadata = Metadata> = {
sessionID: string
messageID: string
abort: AbortSignal
metadata(meta: M): void
metadata(input: { title?: string; metadata?: M }): void
}
export interface Info<
Parameters extends StandardSchemaV1 = StandardSchemaV1,
M extends Metadata = Metadata,
> {
export interface Info<Parameters extends StandardSchemaV1 = StandardSchemaV1, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
@@ -22,15 +18,15 @@ export namespace Tool {
args: StandardSchemaV1.InferOutput<Parameters>,
ctx: Context,
): Promise<{
title: string
metadata: M
output: string
}>
}
export function define<
Parameters extends StandardSchemaV1,
Result extends Metadata,
>(input: Info<Parameters, Result>): Info<Parameters, Result> {
export function define<Parameters extends StandardSchemaV1, Result extends Metadata>(
input: Info<Parameters, Result>,
): Info<Parameters, Result> {
return input
}
}
+17 -41
View File
@@ -14,9 +14,7 @@ export const WebFetchTool = Tool.define({
url: z.string().describe("The URL to fetch content from"),
format: z
.enum(["text", "markdown", "html"])
.describe(
"The format to return the content in (text, markdown, or html)",
),
.describe("The format to return the content in (text, markdown, or html)"),
timeout: z
.number()
.min(0)
@@ -26,17 +24,11 @@ export const WebFetchTool = Tool.define({
}),
async execute(params, ctx) {
// Validate URL
if (
!params.url.startsWith("http://") &&
!params.url.startsWith("https://")
) {
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
}
const timeout = Math.min(
(params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000,
MAX_TIMEOUT,
)
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
@@ -46,8 +38,7 @@ export const WebFetchTool = Tool.define({
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
})
@@ -79,16 +70,14 @@ export const WebFetchTool = Tool.define({
const text = await extractTextFromHTML(content)
return {
output: text,
metadata: {
title,
},
title,
metadata: {},
}
}
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
case "markdown":
@@ -96,32 +85,28 @@ export const WebFetchTool = Tool.define({
const markdown = convertHTMLToMarkdown(content)
return {
output: markdown,
metadata: {
title,
},
title,
metadata: {},
}
}
return {
output: "```\n" + content + "\n```",
metadata: {
title,
},
title,
metadata: {},
}
case "html":
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
default:
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
}
},
@@ -143,16 +128,7 @@ async function extractTextFromHTML(html: string) {
.on("*", {
element(element) {
// Reset skip flag when entering other elements
if (
![
"script",
"style",
"noscript",
"iframe",
"object",
"embed",
].includes(element.tagName)
) {
if (!["script", "style", "noscript", "iframe", "object", "embed"].includes(element.tagName)) {
skipContent = false
}
},
+4 -12
View File
@@ -13,18 +13,12 @@ export const WriteTool = Tool.define({
id: "write",
description: DESCRIPTION,
parameters: z.object({
filePath: z
.string()
.describe(
"The absolute path to the file to write (must be absolute, not relative)",
),
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
}),
async execute(params, ctx) {
const app = App.info()
const filepath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(app.path.cwd, params.filePath)
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
const file = Bun.file(filepath)
const exists = await file.exists()
@@ -33,9 +27,7 @@ export const WriteTool = Tool.define({
await Permission.ask({
id: "write",
sessionID: ctx.sessionID,
title: exists
? "Overwrite this file: " + filepath
: "Create new file: " + filepath,
title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath,
metadata: {
filePath: filepath,
content: params.content,
@@ -62,11 +54,11 @@ export const WriteTool = Tool.define({
}
return {
title: path.relative(app.path.root, filepath),
metadata: {
diagnostics,
filepath,
exists: exists,
title: path.relative(app.path.root, filepath),
},
output,
}
+1 -4
View File
@@ -7,10 +7,7 @@ export abstract class NamedError extends Error {
abstract schema(): ZodSchema
abstract toObject(): { name: string; data: any }
static create<Name extends string, Data extends ZodSchema>(
name: Name,
data: Data,
) {
static create<Name extends string, Data extends ZodSchema>(name: Name, data: Data) {
const schema = z
.object({
name: z.literal(name),
+24
View File
@@ -15,4 +15,28 @@ export namespace Filesystem {
}
return result
}
export async function globUp(pattern: string, start: string, stop?: string) {
let current = start
const result = []
while (true) {
try {
const glob = new Bun.Glob(pattern)
for await (const match of glob.scan({
cwd: current,
onlyFiles: true,
dot: true,
})) {
result.push(join(current, match))
}
} catch {
// Skip invalid glob patterns
}
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
}
}
+3 -12
View File
@@ -19,10 +19,7 @@ export namespace Log {
await fs.mkdir(dir, { recursive: true })
cleanup(dir)
if (options.print) return
logpath = path.join(
dir,
new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
logpath = path.join(dir, new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log")
const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer()
@@ -43,9 +40,7 @@ export namespace Log {
const filesToDelete = files.slice(0, -10)
await Promise.all(
filesToDelete.map((file) => fs.unlink(file).catch(() => {})),
)
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
}
let last = Date.now()
@@ -63,11 +58,7 @@ export namespace Log {
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return (
[next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message]
.filter(Boolean)
.join(" ") + "\n"
)
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result = {
info(message?: any, extra?: Record<string, any>) {
+20 -91
View File
@@ -17,12 +17,7 @@ const testCases: TestCase[] = [
replace: 'console.log("universe");',
},
{
content: [
"if (condition) {",
" doSomething();",
" doSomethingElse();",
"}",
].join("\n"),
content: ["if (condition) {", " doSomething();", " doSomethingElse();", "}"].join("\n"),
find: [" doSomething();", " doSomethingElse();"].join("\n"),
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
},
@@ -53,15 +48,8 @@ const testCases: TestCase[] = [
" return result;",
"}",
].join("\n"),
find: [
"function calculate(a, b) {",
" // different middle content",
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join(
"\n",
),
find: ["function calculate(a, b) {", " // different middle content", " return result;", "}"].join("\n"),
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join("\n"),
},
{
content: [
@@ -76,13 +64,7 @@ const testCases: TestCase[] = [
"}",
].join("\n"),
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
replace: [
"class MyClass {",
" constructor() {",
" this.value = 42;",
" }",
"}",
].join("\n"),
replace: ["class MyClass {", " constructor() {", " this.value = 42;", " }", "}"].join("\n"),
},
// WhitespaceNormalizedReplacer cases
@@ -104,48 +86,21 @@ const testCases: TestCase[] = [
// IndentationFlexibleReplacer cases
{
content: [
" function nested() {",
' console.log("deeply nested");',
" return true;",
" }",
].join("\n"),
find: [
"function nested() {",
' console.log("deeply nested");',
" return true;",
"}",
].join("\n"),
replace: [
"function nested() {",
' console.log("updated");',
" return false;",
"}",
].join("\n"),
content: [" function nested() {", ' console.log("deeply nested");', " return true;", " }"].join(
"\n",
),
find: ["function nested() {", ' console.log("deeply nested");', " return true;", "}"].join("\n"),
replace: ["function nested() {", ' console.log("updated");', " return false;", "}"].join("\n"),
},
{
content: [
" if (true) {",
' console.log("level 1");',
' console.log("level 2");',
" }",
].join("\n"),
find: [
"if (true) {",
'console.log("level 1");',
' console.log("level 2");',
"}",
].join("\n"),
content: [" if (true) {", ' console.log("level 1");', ' console.log("level 2");', " }"].join("\n"),
find: ["if (true) {", 'console.log("level 1");', ' console.log("level 2");', "}"].join("\n"),
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
},
// replaceAll option cases
{
content: [
'console.log("test");',
'console.log("test");',
'console.log("test");',
].join("\n"),
content: ['console.log("test");', 'console.log("test");', 'console.log("test");'].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: true,
@@ -213,9 +168,7 @@ const testCases: TestCase[] = [
// MultiOccurrenceReplacer cases (with replaceAll)
{
content: ["debug('start');", "debug('middle');", "debug('end');"].join(
"\n",
),
content: ["debug('start');", "debug('middle');", "debug('end');"].join("\n"),
find: "debug",
replace: "log",
all: true,
@@ -239,9 +192,7 @@ const testCases: TestCase[] = [
replace: "const value = 24;",
},
{
content: ["", " if (condition) {", " doSomething();", " }", ""].join(
"\n",
),
content: ["", " if (condition) {", " doSomething();", " }", ""].join("\n"),
find: ["if (condition) {", " doSomething();", "}"].join("\n"),
replace: ["if (condition) {", " doNothing();", "}"].join("\n"),
},
@@ -262,9 +213,7 @@ const testCases: TestCase[] = [
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join(
"\n",
),
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join("\n"),
},
{
content: [
@@ -278,15 +227,8 @@ const testCases: TestCase[] = [
" }",
"}",
].join("\n"),
find: [
"class TestClass {",
" // different implementation",
" // with multiple lines",
"}",
].join("\n"),
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join(
"\n",
),
find: ["class TestClass {", " // different implementation", " // with multiple lines", "}"].join("\n"),
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join("\n"),
},
// Combined edge cases for new replacers
@@ -296,9 +238,7 @@ const testCases: TestCase[] = [
replace: 'console.log("updated");',
},
{
content: [" ", "function test() {", " return 'value';", "}", " "].join(
"\n",
),
content: [" ", "function test() {", " return 'value';", "}", " "].join("\n"),
find: ["function test() {", "return 'value';", "}"].join("\n"),
replace: ["function test() {", "return 'new value';", "}"].join("\n"),
},
@@ -346,13 +286,7 @@ const testCases: TestCase[] = [
// ContextAwareReplacer - test with trailing newline in find string
{
content: [
"class Test {",
" method1() {",
" return 1;",
" }",
"}",
].join("\n"),
content: ["class Test {", " method1() {", " return 1;", " }", "}"].join("\n"),
find: [
"class Test {",
" // different content",
@@ -401,12 +335,7 @@ describe("EditTool Replacers", () => {
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
}).toThrow()
} else {
const result = replace(
testCase.content,
testCase.find,
testCase.replace,
testCase.all,
)
const result = replace(testCase.content, testCase.find, testCase.replace, testCase.all)
expect(result).toContain(testCase.replace)
}
})
+1 -4
View File
@@ -42,10 +42,7 @@ describe("tool.glob", () => {
describe("tool.ls", () => {
test("basic", async () => {
const result = await App.provide({ cwd: process.cwd() }, async () => {
return await ListTool.execute(
{ path: "./example", ignore: [".git"] },
ctx,
)
return await ListTool.execute({ path: "./example", ignore: [".git"] }, ctx)
})
expect(result.output).toMatchSnapshot()
})
+14 -1
View File
@@ -9,10 +9,12 @@ import (
"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/tui"
"golang.design/x/clipboard"
)
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")
@@ -61,11 +67,18 @@ func main() {
os.Exit(1)
}
go func() {
err = clipboard.Init()
if err != nil {
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)
}
+19 -12
View File
@@ -10,16 +10,21 @@ 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.design/x/clipboard v0.7.1
rsc.io/qr v0.2.0
)
replace github.com/sst/opencode-sdk-go => ./sdk
replace golang.design/x/clipboard v0.7.1 => github.com/sst/clipboard v0.0.0-20250708194754-36e9fc12a4e8
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
require (
@@ -47,23 +52,25 @@ 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/exp/shiny v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // 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
@@ -75,16 +82,16 @@ 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/image v0.28.0 // indirect
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
)
+24 -21
View File
@@ -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=
@@ -181,8 +181,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
github.com/sst/clipboard v0.0.0-20250708194754-36e9fc12a4e8 h1:GNr7eAnMwAzUNk7eOsw4Flr9c9Uk55Wnf4840DCckwc=
github.com/sst/clipboard v0.0.0-20250708194754-36e9fc12a4e8/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -218,14 +218,17 @@ 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/exp/shiny v0.0.0-20250620022241-b7579e27df2b h1:zELBzk+7ERc6m8BxhzU2VYjp03wlEvi+cIgYQR5H3CI=
golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
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/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8=
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg=
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=
@@ -236,15 +239,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=
@@ -263,28 +266,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=
+173 -104
View File
@@ -18,20 +18,23 @@ import (
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"golang.design/x/clipboard"
)
type App struct {
Info opencode.App
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *config.State
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.Message
Commands commands.CommandRegistry
Info opencode.App
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *config.State
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.MessageUnion
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
}
type SessionSelectedMsg = *opencode.Session
@@ -44,13 +47,10 @@ type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendMsg struct {
Text string
Attachments []Attachment
}
type CompletionDialogTriggeredMsg struct {
InitialValue string
Attachments []opencode.FilePartParam
}
type OptimisticMessageAddedMsg struct {
Message opencode.Message
Message opencode.MessageUnion
}
type FileRenderedMsg struct {
FilePath string
@@ -61,6 +61,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
@@ -112,15 +114,17 @@ func New(
slog.Debug("Loaded config", "config", configInfo)
app := &App{
Info: appInfo,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
Session: &opencode.Session{},
Messages: []opencode.Message{},
Commands: commands.LoadFromConfig(configInfo),
Info: appInfo,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
Session: &opencode.Session{},
Messages: []opencode.MessageUnion{},
Commands: commands.LoadFromConfig(configInfo),
InitialModel: model,
InitialPrompt: prompt,
}
return app, nil
@@ -129,7 +133,11 @@ func New(
func (a *App) Key(commandName commands.CommandName) string {
t := theme.CurrentTheme()
base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
muted := styles.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Faint(true).Render
muted := styles.NewStyle().
Background(t.Background()).
Foreground(t.TextMuted()).
Faint(true).
Render
command := a.Commands[commandName]
kb := command.Keybindings[0]
key := kb.Key
@@ -139,69 +147,108 @@ 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)
// TODO: notify user
return nil
}
providers := providersResponse.Providers
var defaultProvider *opencode.Provider
var defaultModel *opencode.Model
providersResponse, err := a.Client.Config.Providers(context.Background())
if err != nil {
slog.Error("Failed to list providers", "error", err)
// TODO: notify user
return nil
}
providers := providersResponse.Providers
var defaultProvider *opencode.Provider
var defaultModel *opencode.Model
var anthropic *opencode.Provider
for _, provider := range providers {
if provider.ID == "anthropic" {
anthropic = &provider
var anthropic *opencode.Provider
for _, provider := range providers {
if provider.ID == "anthropic" {
anthropic = &provider
}
}
// default to anthropic if available
if anthropic != nil {
defaultProvider = anthropic
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
for _, provider := range providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
}
providers = append(providers, provider)
}
if len(providers) == 0 {
slog.Error("No providers configured")
return nil
}
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
if provider.ID == a.State.Provider {
currentProvider = &provider
for _, model := range provider.Models {
if model.ID == a.State.Model {
currentModel = &model
}
}
}
}
if currentProvider == nil || currentModel == nil {
currentProvider = defaultProvider
currentModel = defaultModel
}
// default to anthropic if available
if anthropic != nil {
defaultProvider = anthropic
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
var initialProvider *opencode.Provider
var initialModel *opencode.Model
if a.InitialModel != nil && *a.InitialModel != "" {
splits := strings.Split(*a.InitialModel, "/")
for _, provider := range providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
}
providers = append(providers, provider)
}
if len(providers) == 0 {
slog.Error("No providers configured")
return nil
}
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
if provider.ID == a.State.Provider {
currentProvider = &provider
if provider.ID == splits[0] {
initialProvider = &provider
for _, model := range provider.Models {
if model.ID == a.State.Model {
currentModel = &model
modelID := strings.Join(splits[1:], "/")
if model.ID == modelID {
initialModel = &model
}
}
}
}
if currentProvider == nil || currentModel == nil {
currentProvider = defaultProvider
currentModel = defaultModel
}
return ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
}
}
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(response *opencode.ConfigProvidersResponse, provider opencode.Provider) *opencode.Model {
func getDefaultModel(
response *opencode.ConfigProvidersResponse,
provider opencode.Provider,
) *opencode.Model {
if match, ok := response.Default[provider.ID]; ok {
model := provider.Models[match]
return &model
@@ -213,20 +260,16 @@ func getDefaultModel(response *opencode.ConfigProvidersResponse, provider openco
return nil
}
type Attachment struct {
FilePath string
FileName string
MimeType string
Content []byte
}
func (a *App) IsBusy() bool {
if len(a.Messages) == 0 {
return false
}
lastMessage := a.Messages[len(a.Messages)-1]
return lastMessage.Metadata.Time.Completed == 0
if casted, ok := lastMessage.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0
}
return false
}
func (a *App) SaveState() {
@@ -292,29 +335,43 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
return session, nil
}
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
func (a *App) SendChatMessage(
ctx context.Context,
text string,
attachments []opencode.FilePartParam,
) (*App, tea.Cmd) {
var cmds []tea.Cmd
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
if err != nil {
return toast.NewErrorToast(err.Error())
return a, toast.NewErrorToast(err.Error())
}
a.Session = session
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
}
optimisticMessage := opencode.Message{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.MessageRoleUser,
Parts: []opencode.MessagePart{{
Type: opencode.MessagePartTypeText,
Text: text,
}},
Metadata: opencode.MessageMetadata{
SessionID: a.Session.ID,
Time: opencode.MessageMetadataTime{
Created: float64(time.Now().Unix()),
},
optimisticParts := []opencode.UserMessagePart{{
Type: opencode.UserMessagePartTypeText,
Text: text,
}}
if len(attachments) > 0 {
for _, attachment := range attachments {
optimisticParts = append(optimisticParts, opencode.UserMessagePart{
Type: opencode.UserMessagePartTypeFile,
Filename: attachment.Filename.Value,
Mime: attachment.Mime.Value,
URL: attachment.URL.Value,
})
}
}
optimisticMessage := opencode.UserMessage{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.UserMessageRoleUser,
Parts: optimisticParts,
SessionID: a.Session.ID,
Time: opencode.UserMessageTime{
Created: float64(time.Now().Unix()),
},
}
@@ -322,13 +379,25 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
cmds = append(cmds, func() tea.Msg {
parts := []opencode.UserMessagePartUnionParam{
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
},
}
if len(attachments) > 0 {
for _, attachment := range attachments {
parts = append(parts, opencode.FilePartParam{
Mime: attachment.Mime,
Type: attachment.Type,
URL: attachment.URL,
Filename: attachment.Filename,
})
}
}
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
Parts: opencode.F([]opencode.MessagePartUnionParam{
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
},
}),
Parts: opencode.F(parts),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
@@ -342,7 +411,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
// The actual response will come through SSE
// For now, just return success
return tea.Batch(cmds...)
return a, tea.Batch(cmds...)
}
func (a *App) Cancel(ctx context.Context, sessionID string) error {
+37 -15
View File
@@ -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 {
@@ -75,6 +90,7 @@ const (
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
SessionShareCommand CommandName = "session_share"
SessionUnshareCommand CommandName = "session_unshare"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
ToolDetailsCommand CommandName = "tool_details"
@@ -134,31 +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: []string{"unshare"},
},
{
Name: SessionInterruptCommand,
@@ -169,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,
@@ -214,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,
@@ -224,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,
@@ -289,13 +311,13 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
{
Name: MessagesRevertCommand,
Description: "revert message",
Keybindings: parseBindings("<leader>u"),
Keybindings: parseBindings("<leader>r"),
},
{
Name: AppExitCommand,
Description: "exit the app",
Keybindings: parseBindings("ctrl+c", "<leader>q"),
Trigger: "exit",
Trigger: []string{"exit", "quit"},
},
}
registry := make(CommandRegistry)
+12 -9
View File
@@ -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 {
@@ -61,7 +60,9 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
return items
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
func (cg *filesAndFoldersContextGroup) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
items := make([]dialog.CompletionItemI, 0)
query = strings.TrimSpace(query)
@@ -75,6 +76,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C
)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
return items, err
}
if files == nil {
return items, nil
}
for _, file := range *files {
@@ -101,9 +106,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
cg := &filesAndFoldersContextGroup{
app: app,
prefix: "file",
app: app,
}
cg.gitFiles = cg.getGitFiles()
go func() {
cg.gitFiles = cg.getGitFiles()
}()
return cg
}
@@ -1,32 +0,0 @@
package completions
import (
"strings"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
)
type CompletionManager struct {
providers map[string]dialog.CompletionProvider
}
func NewCompletionManager(app *app.App) *CompletionManager {
return &CompletionManager{
providers: map[string]dialog.CompletionProvider{
"files": NewFileAndFolderContextGroup(app),
"commands": NewCommandCompletionProvider(app),
},
}
}
func (m *CompletionManager) DefaultProvider() dialog.CompletionProvider {
return m.providers["commands"]
}
func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider {
if strings.HasPrefix(input, "/") {
return m.providers["commands"]
}
return m.providers["files"]
}
+179 -40
View File
@@ -1,21 +1,27 @@
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/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"
"golang.design/x/clipboard"
)
type EditorComponent interface {
@@ -37,7 +43,6 @@ type EditorComponent interface {
type editorComponent struct {
app *app.App
textarea textarea.Model
attachments []app.Attachment
spinner spinner.Model
interruptKeyInDebounce bool
}
@@ -61,22 +66,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 + " ")
@@ -128,7 +227,15 @@ func (m *editorComponent) Content(width int) string {
if m.app.IsBusy() {
keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
hint = muted(
"working",
) + m.spinner.View() + muted(
" ",
) + base(
keyText+" again",
) + muted(
" interrupt",
)
} else {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
}
@@ -190,19 +297,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 +329,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
}
return m, nil
// fallback to reading the clipboard using OSC52
return m, tea.ReadClipboard
}
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
@@ -244,34 +373,37 @@ func (m *editorComponent) getSubmitKeyText() string {
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
}
func createTextArea(existing *textarea.Model) textarea.Model {
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 +423,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
}
+179 -178
View File
@@ -17,7 +17,6 @@ import (
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/tidwall/gjson"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@@ -162,18 +161,16 @@ func renderContentBlock(
if highlight {
style = style.
BorderLeftBackground(t.Primary()).
BorderLeftForeground(t.Primary()).
BorderRightForeground(t.Primary()).
BorderRightBackground(t.Primary())
BorderLeftForeground(borderColor).
BorderRightForeground(borderColor)
}
}
if highlight {
style = style.
Foreground(t.Text()).
Bold(true).
Background(t.BackgroundElement())
Background(t.BackgroundElement()).
Bold(true)
}
content = style.Render(content)
@@ -211,7 +208,7 @@ func renderContentBlock(
)
header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header)
content = "\n\n\n" + header + "\n\n" + content + "\n\n"
content = "\n\n\n" + header + "\n\n" + content + "\n\n\n"
}
return content
@@ -219,17 +216,37 @@ 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)).Local().Format("02 Jan 2006 03:04 PM")
var ts time.Time
backgroundColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
}
messageStyle := styles.NewStyle().Background(backgroundColor)
content := messageStyle.Render(text)
switch casted := message.(type) {
case opencode.AssistantMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
content = util.ToMarkdown(text, width, backgroundColor)
case opencode.UserMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
messageStyle = messageStyle.Width(width - 6)
}
timestamp := ts.
Local().
Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
// don't show the date if it's today
timestamp = timestamp[12:]
@@ -237,30 +254,12 @@ func renderText(
info := fmt.Sprintf("%s (%s)", author, timestamp)
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
backgroundColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
}
messageStyle := styles.NewStyle().Background(backgroundColor)
if message.Role == opencode.MessageRoleUser {
messageStyle = messageStyle.Width(width - 6)
}
content := messageStyle.Render(text)
if message.Role == opencode.MessageRoleAssistant {
content = util.ToMarkdown(text, width, backgroundColor)
}
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
content = content + "\n\n"
for _, toolCall := range toolCalls {
title := renderToolTitle(toolCall, message.Metadata, width)
metadata := opencode.MessageMetadataTool{}
if _, ok := message.Metadata.Tool[toolCall.ToolInvocation.ToolCallID]; ok {
metadata = message.Metadata.Tool[toolCall.ToolInvocation.ToolCallID]
}
title := renderToolTitle(toolCall, width)
style := styles.NewStyle()
if _, ok := metadata.ExtraFields["error"]; ok {
if toolCall.State.Status == opencode.ToolPartStateStatusError {
style = style.Foreground(t.Error())
}
title = style.Render(title)
@@ -269,10 +268,14 @@ func renderText(
}
}
content = strings.Join([]string{content, info}, "\n")
sections := []string{content, info}
if extra != "" {
sections = append(sections, "\n"+extra)
}
content = strings.Join(sections, "\n")
switch message.Role {
case opencode.MessageRoleUser:
switch message.(type) {
case opencode.UserMessage:
return renderContentBlock(
app,
content,
@@ -281,7 +284,7 @@ func renderText(
WithTextColor(t.Text()),
WithBorderColorRight(t.Secondary()),
)
case opencode.MessageRoleAssistant:
case opencode.AssistantMessage:
return renderContentBlock(
app,
content,
@@ -295,39 +298,32 @@ func renderText(
func renderToolDetails(
app *app.App,
toolCall opencode.ToolInvocationPart,
messageMetadata opencode.MessageMetadata,
toolCall opencode.ToolPart,
highlight bool,
width int,
) string {
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
if slices.Contains(ignoredTools, toolCall.Tool) {
return ""
}
toolCallID := toolCall.ToolInvocation.ToolCallID
metadata := opencode.MessageMetadataTool{}
if _, ok := messageMetadata.Tool[toolCallID]; ok {
metadata = messageMetadata.Tool[toolCallID]
}
var result *string
if toolCall.ToolInvocation.Result != "" {
result = &toolCall.ToolInvocation.Result
}
if toolCall.ToolInvocation.State == "partial-call" {
title := renderToolTitle(toolCall, messageMetadata, width)
if toolCall.State.Status == opencode.ToolPartStateStatusPending || toolCall.State.Status == opencode.ToolPartStateStatusRunning {
title := renderToolTitle(toolCall, width)
return renderContentBlock(app, title, highlight, width)
}
toolArgsMap := make(map[string]any)
if toolCall.ToolInvocation.Args != nil {
value := toolCall.ToolInvocation.Args
var result *string
if toolCall.State.Output != "" {
result = &toolCall.State.Output
}
toolInputMap := make(map[string]any)
if toolCall.State.Input != nil {
value := toolCall.State.Input
if m, ok := value.(map[string]any); ok {
toolArgsMap = m
keys := make([]string, 0, len(toolArgsMap))
for key := range toolArgsMap {
toolInputMap = m
keys := make([]string, 0, len(toolInputMap))
for key := range toolInputMap {
keys = append(keys, key)
}
slices.Sort(keys)
@@ -335,133 +331,136 @@ func renderToolDetails(
}
body := ""
finished := result != nil && *result != ""
t := theme.CurrentTheme()
backgroundColor := t.BackgroundPanel()
borderColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
borderColor = t.BorderActive()
}
switch toolCall.ToolInvocation.ToolName {
case "read":
preview := metadata.ExtraFields["preview"]
if preview != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
}
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
diffField := metadata.ExtraFields["diff"]
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(width-2),
)
body = strings.TrimSpace(formattedDiff)
style := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Padding(1, 2).Width(width - 4)
if highlight {
style = style.Foreground(t.Text()).Bold(true)
}
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
title := renderToolTitle(toolCall, messageMetadata, width)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(app, content, highlight, width, WithPadding(0))
return content
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
metadata := toolCall.State.Metadata.(map[string]any)
switch toolCall.Tool {
case "read":
preview := metadata["preview"]
if preview != nil && toolInputMap["filePath"] != nil {
filename := toolInputMap["filePath"].(string)
body = preview.(string)
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
}
}
case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
if content, ok := toolArgsMap["content"].(string); ok {
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics
case "edit":
if filename, ok := toolInputMap["filePath"].(string); ok {
diffField := metadata["diff"]
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(width-2),
)
body = strings.TrimSpace(formattedDiff)
style := styles.NewStyle().
Background(backgroundColor).
Foreground(t.TextMuted()).
Padding(1, 2).
Width(width - 4)
if highlight {
style = style.Foreground(t.Text()).Bold(true)
}
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
title := renderToolTitle(toolCall, width)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(
app,
content,
highlight,
width,
WithPadding(0),
WithBorderColor(borderColor),
)
return content
}
}
}
case "bash":
stdout := metadata.ExtraFields["stdout"]
if stdout != nil {
command := toolArgsMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = util.ToMarkdown(body, width, backgroundColor)
}
case "webfetch":
if format, ok := toolArgsMap["format"].(string); ok && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" {
case "write":
if filename, ok := toolInputMap["filePath"].(string); ok {
if content, ok := toolInputMap["content"].(string); ok {
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics
}
}
}
case "bash":
stdout := metadata["stdout"]
if stdout != nil {
command := toolInputMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = util.ToMarkdown(body, width, backgroundColor)
}
}
case "todowrite":
todos := metadata.JSON.ExtraFields["todos"]
if !todos.IsNull() && finished {
strTodos := todos.Raw()
todos := gjson.Parse(strTodos)
for _, todo := range todos.Array() {
content := todo.Get("content").String()
switch todo.Get("status").String() {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] %s\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
case "webfetch":
if format, ok := toolInputMap["format"].(string); ok && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = util.ToMarkdown(body, width, backgroundColor)
}
}
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata.JSON.ExtraFields["summary"]
if !summary.IsNull() {
strValue := summary.Raw()
toolcalls := gjson.Parse(strValue).Array()
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.Value().(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall opencode.ToolInvocationPart
_ = json.Unmarshal(data, &toolCall)
if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata)
var toolMetadata opencode.MessageMetadataTool
_ = json.Unmarshal(data, &toolMetadata)
step := renderToolTitle(toolCall, messageMetadata, width)
case "todowrite":
todos := metadata["todos"]
if todos != nil {
for _, item := range todos.([]any) {
todo := item.(map[string]any)
content := todo["content"].(string)
switch todo["status"] {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] %s\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata["summary"]
if summary != nil {
toolcalls := summary.([]any)
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall opencode.ToolPart
_ = json.Unmarshal(data, &toolCall)
step := renderToolTitle(toolCall, width)
step = "∟ " + step
steps = append(steps, step)
}
}
body = strings.Join(steps, "\n")
}
body = strings.Join(steps, "\n")
default:
if result == nil {
empty := ""
result = &empty
}
body = *result
body = util.TruncateHeight(body, 10)
}
default:
if result == nil {
empty := ""
result = &empty
}
body = *result
body = util.TruncateHeight(body, 10)
}
error := ""
if err, ok := metadata.ExtraFields["error"].(bool); ok && err {
if message, ok := metadata.ExtraFields["message"].(string); ok {
error = message
}
if toolCall.State.Status == opencode.ToolPartStateStatusError {
error = toolCall.State.Error
}
if error != "" {
@@ -476,9 +475,9 @@ func renderToolDetails(
body = util.TruncateHeight(body, 10)
}
title := renderToolTitle(toolCall, messageMetadata, width)
title := renderToolTitle(toolCall, width)
content := title + "\n\n" + body
return renderContentBlock(app, content, highlight, width)
return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor))
}
func renderToolName(name string) string {
@@ -489,28 +488,27 @@ func renderToolName(name string) string {
return "Plan"
default:
normalizedName := name
if strings.HasPrefix(name, "opencode_") {
normalizedName = strings.TrimPrefix(name, "opencode_")
if after, ok := strings.CutPrefix(name, "opencode_"); ok {
normalizedName = after
}
return cases.Title(language.Und).String(normalizedName)
}
}
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
@@ -528,8 +526,8 @@ func renderToolTitle(
}
}
title := renderToolName(toolCall.ToolInvocation.ToolName)
switch toolCall.ToolInvocation.ToolName {
title := renderToolName(toolCall.Tool)
switch toolCall.Tool {
case "read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("%s %s", title, toolArgs)
@@ -547,7 +545,7 @@ func renderToolTitle(
case "todowrite", "todoread":
// title is just the tool name
default:
toolName := renderToolName(toolCall.ToolInvocation.ToolName)
toolName := renderToolName(toolCall.Tool)
title = fmt.Sprintf("%s %s", toolName, toolArgs)
}
return title
@@ -627,8 +625,8 @@ type Diagnostic struct {
}
// renderDiagnostics formats LSP diagnostics for display in the TUI
func renderDiagnostics(metadata opencode.MessageMetadataTool, filePath string) string {
if diagnosticsData, ok := metadata.ExtraFields["diagnostics"].(map[string]any); ok {
func renderDiagnostics(metadata map[string]any, filePath string) string {
if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
var errorDiagnostics []string
for _, diagInterface := range fileDiagnostics {
@@ -651,7 +649,10 @@ func renderDiagnostics(metadata opencode.MessageMetadataTool, filePath string) s
}
line := diag.Range.Start.Line + 1 // 1-based
column := diag.Range.Start.Character + 1 // 1-based
errorDiagnostics = append(errorDiagnostics, fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message))
errorDiagnostics = append(
errorDiagnostics,
fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
)
}
if len(errorDiagnostics) == 0 {
return ""
+122 -62
View File
@@ -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
@@ -80,15 +79,11 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.showToolDetails = !m.showToolDetails
m.rendering = true
return m, m.Reload()
case app.SessionLoadedMsg:
case app.SessionLoadedMsg, app.SessionClearedMsg:
m.cache.Clear()
m.tail = true
m.rendering = true
return m, m.Reload()
case app.SessionClearedMsg:
m.cache.Clear()
m.rendering = true
return m, m.Reload()
case renderFinishedMsg:
m.rendering = false
if m.tail {
@@ -104,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()
@@ -129,16 +124,58 @@ func (m *messagesComponent) renderView(width int) {
m.partCount = 0
m.lineCount = 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(
@@ -149,37 +186,49 @@ func (m *messagesComponent) renderView(width int) {
m.showToolDetails,
m.partCount == m.selectedPart,
width,
files,
)
m.cache.Set(key, content)
}
if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = part.Text
}
m = m.updateSelected(content, part.Text)
blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
}
// Only render the first text part
break userLoop
}
}
case opencode.MessageRoleAssistant:
for i, p := range message.Parts {
case opencode.AssistantMessage:
hasTextPart := false
for partIndex, p := range casted.Parts {
switch part := p.AsUnion().(type) {
case opencode.TextPart:
finished := message.Metadata.Time.Completed > 0
remainingParts := message.Parts[i+1:]
toolCallParts := make([]opencode.ToolInvocationPart, 0)
hasTextPart = true
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.ToolPart, 0)
}
remaining := true
for _, part := range remainingParts {
if !remaining {
break
}
switch part := part.AsUnion().(type) {
case opencode.TextPart:
// we only want tool calls associated with the current text part.
// if we hit another text part, we're done.
break
case opencode.ToolInvocationPart:
remaining = false
case opencode.ToolPart:
toolCallParts = append(toolCallParts, part)
if part.ToolInvocation.State != "result" {
if part.State.Status != opencode.ToolPartStateStatusCompleted || part.State.Status != opencode.ToolPartStateStatusError {
// i don't think there's a case where a tool call isn't in result state
// and the message time is 0, but just in case
finished = false
@@ -188,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)
@@ -208,30 +258,29 @@ 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...,
)
}
if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = p.Text
}
m = m.updateSelected(content, p.Text)
blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
}
case opencode.ToolInvocationPart:
case opencode.ToolPart:
if !m.showToolDetails {
if !hasTextPart {
orphanedToolCalls = append(orphanedToolCalls, part)
}
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,
@@ -241,7 +290,6 @@ func (m *messagesComponent) renderView(width int) {
content = renderToolDetails(
m.app,
part,
message.Metadata,
m.partCount == m.selectedPart,
width,
)
@@ -252,33 +300,31 @@ func (m *messagesComponent) renderView(width int) {
content = renderToolDetails(
m.app,
part,
message.Metadata,
m.partCount == m.selectedPart,
width,
)
}
if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = ""
}
m = m.updateSelected(content, "")
blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
}
}
}
}
error := ""
switch err := message.Metadata.Error.AsUnion().(type) {
case nil:
case opencode.MessageMetadataErrorMessageOutputLengthError:
error = "Message output length exceeded"
case opencode.ProviderAuthError:
error = err.Data.Message
case opencode.UnknownError:
error = err.Data.Message
if assistant, ok := message.(opencode.AssistantMessage); ok {
switch err := assistant.Error.AsUnion().(type) {
case nil:
case opencode.AssistantMessageErrorMessageOutputLengthError:
error = "Message output length exceeded"
case opencode.ProviderAuthError:
error = err.Data.Message
case opencode.MessageAbortedError:
error = "Request was aborted"
case opencode.UnknownError:
error = err.Data.Message
}
}
if error != "" {
@@ -295,9 +341,20 @@ func (m *messagesComponent) renderView(width int) {
}
m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n"))
if m.selectedPart == m.partCount-1 {
if m.selectedPart == m.partCount {
m.viewport.GotoBottom()
}
}
func (m *messagesComponent) updateSelected(content string, selectedText string) *messagesComponent {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - (m.viewport.Height() / 2) + 4)
m.selectedText = selectedText
}
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
return m
}
func (m *messagesComponent) header(width int) string {
@@ -309,9 +366,12 @@ func (m *messagesComponent) header(width int) string {
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
headerLines := []string{}
headerLines = append(headerLines, util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
headerLines = append(
headerLines,
util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()),
)
if m.app.Session.Share.URL != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.URL))
headerLines = append(headerLines, muted(m.app.Session.Share.URL+" /unshare"))
} else {
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
}
@@ -340,7 +400,7 @@ func (m *messagesComponent) View(width, height int) string {
height,
lipgloss.Center,
lipgloss.Center,
styles.NewStyle().Background(t.Background()).Render("Loading session..."),
styles.NewStyle().Background(t.Background()).Render(""),
styles.WhitespaceStyle(t.Background()),
)
}
@@ -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)
}
@@ -7,7 +7,6 @@ import (
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -65,7 +64,7 @@ type CompletionProvider interface {
type CompletionSelectedMsg struct {
SearchString string
CompletionValue string
IsCommand bool
ProviderID string
}
type CompletionDialogCompleteItemMsg struct {
@@ -79,7 +78,6 @@ type CompletionDialog interface {
tea.ViewModel
SetWidth(width int)
IsEmpty() bool
SetProvider(provider CompletionProvider)
}
type completionDialogComponent struct {
@@ -114,8 +112,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case []CompletionItemI:
c.list.SetItems(msg)
case app.CompletionDialogTriggeredMsg:
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) {
@@ -125,9 +121,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var query string
query = c.pseudoSearchTextArea.Value()
if query != "" {
query = query[1:]
}
if query != c.query {
c.query = query
@@ -187,8 +180,9 @@ func (c *completionDialogComponent) View() string {
for _, cmd := range completions {
title := cmd.DisplayValue()
if len(title) > maxWidth-4 {
maxWidth = len(title) + 4
width := lipgloss.Width(title)
if width > maxWidth-4 {
maxWidth = width + 4
}
}
@@ -214,28 +208,14 @@ func (c *completionDialogComponent) IsEmpty() bool {
return c.list.IsEmpty()
}
func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
if c.completionProvider.GetId() != provider.GetId() {
c.completionProvider = provider
c.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
c.list.SetItems([]CompletionItemI{})
}
}
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
// Check if this is a command completion
isCommand := c.completionProvider.GetId() == "commands"
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
IsCommand: isCommand,
ProviderID: c.completionProvider.GetId(),
}),
c.close(),
)
+10 -12
View File
@@ -27,7 +27,6 @@ type FindDialog interface {
SetWidth(width int)
SetHeight(height int)
IsEmpty() bool
SetProvider(provider CompletionProvider)
}
type findDialogComponent struct {
@@ -125,7 +124,7 @@ func (f *findDialogComponent) View() string {
f.list.SetMaxWidth(f.width - 4)
inputView := f.textInput.View()
inputView = styles.NewStyle().
Background(t.BackgroundPanel()).
Background(t.BackgroundElement()).
Height(1).
Width(f.width-4).
Padding(0, 0).
@@ -151,12 +150,6 @@ func (f *findDialogComponent) IsEmpty() bool {
return f.list.IsEmpty()
}
func (f *findDialogComponent) SetProvider(provider CompletionProvider) {
f.completionProvider = provider
f.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
f.list.SetItems([]CompletionItemI{})
}
func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd {
return tea.Sequence(
f.Close(),
@@ -178,15 +171,21 @@ func (f *findDialogComponent) Close() tea.Cmd {
func createTextInput(existing *textinput.Model) textinput.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundPanel()
bgColor := t.BackgroundElement()
textColor := t.Text()
textMutedColor := t.TextMuted()
ti := textinput.New()
ti.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ti.Styles.Blurred.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ti.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ti.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ti.Styles.Focused.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ti.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ti.Styles.Cursor.Color = t.Primary()
ti.VirtualCursor = true
@@ -213,7 +212,6 @@ func NewFindDialog(completionProvider CompletionProvider) FindDialog {
false,
)
// Load initial items
go func() {
items, err := completionProvider.GetChildEntries("")
if err != nil {
@@ -56,24 +56,24 @@ func (m ModelItem) Render(selected bool, width int) string {
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
return styles.NewStyle().
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Foreground(t.BackgroundPanel()).
Width(width).
PaddingLeft(1).
Render(displayText)
} else {
modelStyle := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundElement())
Background(t.BackgroundPanel())
providerStyle := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundElement())
Background(t.BackgroundPanel())
modelPart := modelStyle.Render(m.ModelName)
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
combinedText := modelPart + providerPart
return styles.NewStyle().
Background(t.BackgroundElement()).
Background(t.BackgroundPanel()).
PaddingLeft(1).
Render(combinedText)
}
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"sync"
"unicode/utf8"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
@@ -575,7 +576,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
ansiSequences[visibleIdx] = lastAnsiSeq
}
visibleIdx++
i++
// Properly advance by UTF-8 rune, not byte
_, size := utf8.DecodeRuneInString(content[i:])
i += size
}
// Apply highlighting
@@ -622,8 +626,9 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
}
}
// Get current character
char := string(content[i])
// Get current character (properly handle UTF-8)
r, size := utf8.DecodeRuneInString(content[i:])
char := string(r)
if inSelection {
// Get the current styling
@@ -657,7 +662,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
}
currentPos++
i++
i += size
}
return sb.String()
+12 -2
View File
@@ -158,7 +158,12 @@ func (c *listComponent[T]) View() string {
return strings.Join(listItems, "\n")
}
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
func NewListComponent[T ListItem](
items []T,
maxVisibleItems int,
fallbackMsg string,
useAlphaNumericKeys bool,
) List[T] {
return &listComponent[T]{
fallbackMsg: fallbackMsg,
items: items,
@@ -194,7 +199,12 @@ func (s StringItem) Render(selected bool, width int) string {
}
// NewStringList creates a new list component with string items
func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] {
func NewStringList(
items []string,
maxVisibleItems int,
fallbackMsg string,
useAlphaNumericKeys bool,
) List[StringItem] {
stringItems := make([]StringItem, len(items))
for i, item := range items {
stringItems[i] = StringItem(item)
@@ -90,7 +90,7 @@ func (m *Modal) Render(contentView string, background string) string {
innerWidth := outerWidth - 4
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement())
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel())
var finalContent string
if m.title != "" {
@@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string {
modalView,
background,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(t.BorderActive()),
layout.WithOverlayBorderColor(t.Primary()),
)
}
+1 -1
View File
@@ -23,7 +23,7 @@ func Generate(text string) (string, int, error) {
}
// Create lipgloss style for QR code with theme colors
qrStyle := styles.NewStyleWithColors(t.Text(), t.Background())
qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
var result strings.Builder
@@ -6,6 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -37,7 +38,11 @@ func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m statusComponent) logo() string {
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
emphasis := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundElement()).Bold(true).Render
emphasis := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundElement()).
Bold(true).
Render
open := base("open")
code := emphasis("code ")
@@ -72,19 +77,16 @@ func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) st
formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Context: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
return fmt.Sprintf(
"Context: %s (%d%%), Cost: %s",
formattedTokens,
int(percentage),
formattedCost,
)
}
func (m statusComponent) View() string {
t := theme.CurrentTheme()
if m.app.Session.ID == "" {
return styles.NewStyle().
Background(t.Background()).
Width(m.width).
Height(2).
Render("")
}
logo := m.logo()
cwd := styles.NewStyle().
@@ -100,18 +102,20 @@ func (m statusComponent) View() string {
contextWindow := m.app.Model.Limit.Context
for _, message := range m.app.Messages {
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
if usage.Output > 0 {
if message.Metadata.Assistant.Summary {
tokens = usage.Output
continue
if assistant, ok := message.(opencode.AssistantMessage); ok {
cost += assistant.Cost
usage := assistant.Tokens
if usage.Output > 0 {
if assistant.Summary {
tokens = usage.Output
continue
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,46 +0,0 @@
//go:build !windows
package image
import (
"bytes"
"fmt"
"github.com/atotto/clipboard"
"image"
)
func GetImageFromClipboard() ([]byte, string, error) {
text, err := clipboard.ReadAll()
if err != nil {
return nil, "", fmt.Errorf("Error reading clipboard")
}
if text == "" {
return nil, "", nil
}
binaryData := []byte(text)
imageBytes, err := binaryToImage(binaryData)
if err != nil {
return nil, text, nil
}
return imageBytes, "", nil
}
func binaryToImage(data []byte) ([]byte, error) {
reader := bytes.NewReader(data)
img, _, err := image.Decode(reader)
if err != nil {
return nil, fmt.Errorf("Unable to covert bytes to image")
}
return ImageToBytes(img)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

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