Compare commits

..
94 Commits
Author SHA1 Message Date
Jay V 76b2e4539c docs: discord
publish / publish (push) Has been cancelled
2025-07-07 14:44:37 -04:00
Dominik EngelhardtandGitHub d87922c0eb Fix Elixir LSP startup (#726) 2025-07-06 23:37:46 -04:00
Liang-Shih LinandGitHub 2446483df5 fix: Skip opencode upgrade if same version (#720) 2025-07-06 23:36:59 -04:00
GitHub Action f4c453155d Update download stats 2025-07-06 2025-07-06 12:03:56 +00:00
Dax Raad 969ad80ed2 fix openrouter caching with anthropic, should be a lot cheaper 2025-07-05 11:39:54 -04:00
GitHub Action af064b41d7 Update download stats 2025-07-05 2025-07-05 12:03:56 +00:00
Dax Raad ea6bfef21a use full filepath
publish / publish (push) Has been cancelled
2025-07-04 17:58:03 -04:00
Jay V 107363b1d9 docs: fix show more in share page 2025-07-04 17:57:12 -04:00
Dax Raad 85214d7c59 fix input bar not rendering capital letters
publish / publish (push) Has been cancelled
2025-07-04 17:21:51 -04:00
Timo ClasenandGitHub 997cb2d945 fix(tui): optimistic rendering (#692) 2025-07-04 16:06:57 -05:00
Dax Raad 45b139390c make file attachments work good like
publish / publish (push) Has been cancelled
2025-07-04 16:21:26 -04:00
Jay V 994368de15 docs: share fix scrolling again 2025-07-04 13:53:25 -04:00
Jay V 143fd8e076 docs: share improve markdown rendering of ai responses 2025-07-04 13:53:25 -04:00
Dax Raad 06dba28bd6 wip: fix media type 2025-07-04 12:50:52 -04:00
adamdottv b8d276a049 fix(tui): full paths for attachments 2025-07-04 11:42:22 -05:00
Dax Raad ee01f01271 file attachments 2025-07-04 12:24:01 -04:00
adamdottv 32d5db4f0a fix(tui): markdown wrapping off sometimes 2025-07-04 11:16:38 -05:00
adamdottv f6108b7be8 fix(tui): handle pdf and image @ files 2025-07-04 11:13:09 -05:00
adamdottv 94ef341c9d feat(tui): render attachments 2025-07-04 10:55:02 -05:00
adamdottv f9abc7c84f feat(tui): file attachments 2025-07-04 10:55:02 -05:00
adamdottv 891ed6ebc0 fix(tui): slower startup due to file.status 2025-07-04 10:55:01 -05:00
Dax Raad 163e23a68b removed banned command concept 2025-07-04 11:32:12 -04:00
VladimirandGitHub f13b0af491 docs: Fix invalid json in the mcp example config (#645) 2025-07-04 11:24:13 -04:00
Aiden ClineandGitHub 4a0be45d3d chore: document instructions configuration option (#670) 2025-07-04 11:22:45 -04:00
Dax Raad 23788674c8 disable snapshots temporarily
publish / publish (push) Has been cancelled
2025-07-04 08:45:18 -04:00
GitHub Action 121eb24e73 Update download stats 2025-07-04 2025-07-04 12:26:16 +00:00
Dax Raad 571d60182a improve snapshotting speed further
publish / publish (push) Has been cancelled
2025-07-03 21:36:09 -04:00
Jay V 167a9dcaf3 docs: share fix scroll to anchor 2025-07-03 20:30:21 -04:00
Dax Raad 37327259cb ci: ignore 2025-07-03 20:30:02 -04:00
Dax Raad cdb25656d5 improve snapshot speed
publish / publish (push) Has been cancelled
2025-07-03 20:16:25 -04:00
Jay V 25c876caa2 docs: share fix last message not expandable 2025-07-03 19:33:55 -04:00
Dax Raad cf83e31f23 add elixir lsp support
publish / publish (push) Has been cancelled
2025-07-03 19:29:51 -04:00
Dax Raad 3bc238b58b wip: logs 2025-07-03 19:29:51 -04:00
Jay V b8de69dced docs: fix share page scroll performance 2025-07-03 19:15:38 -04:00
Jay V e7fcb692a4 docs: tweak page title 2025-07-03 16:23:08 -04:00
Timo ClasenandGitHub dae38574ab chore: add dev script (#666) 2025-07-03 14:43:25 -05:00
Dax Raad ed4f862b49 fix /unshare
publish / publish (push) Has been cancelled
2025-07-03 15:34:04 -04:00
adamdottv fce59db94a chore: simplify completions 2025-07-03 12:48:22 -05:00
Jay V 3e2a0c7281 docs: share handle slow loading pages 2025-07-03 13:15:21 -04:00
adamdottv 5a0910ea79 chore: better local dev with stainless script 2025-07-03 11:49:15 -05:00
adamdottv 1dffabcfda fix(tui): panic on completions failure
publish / publish (push) Has been cancelled
2025-07-03 10:53:43 -05:00
adamdottv c389e0ed43 fix(tui): redundant tool calls in each message in collapsed mode
publish / publish (push) Has been cancelled
2025-07-03 10:42:27 -05:00
Dax Raad 204801052a flag for disabling file watcher 2025-07-03 10:37:08 -04:00
Dax Raad 2528d8cb88 increase max retries to 10
publish / publish (push) Has been cancelled
2025-07-03 10:32:55 -04:00
adamdottv 6b73ffd1c1 fix(tui): include orphaned tool calls 2025-07-03 09:32:44 -05:00
adamdottv 0eadc50a33 fix(tui): selected message visuals 2025-07-03 09:03:04 -05:00
Dax Raad aeea84a877 fix webdomain 2025-07-03 09:58:25 -04:00
GitHub Action a54c5c6298 Update download stats 2025-07-03 2025-07-03 12:26:51 +00:00
adamdottv 8825cd3811 feat(tui): unshare command
publish / publish (push) Has been cancelled
2025-07-03 07:09:09 -05:00
adamdottv 3d9a5d9970 fix(tui): always show status bar 2025-07-03 06:53:05 -05:00
adamdottv 1f9e195fa6 fix(tui): better highlight visuals 2025-07-03 06:49:37 -05:00
Craig AndrewsandGitHub 73c012c76c fix: simplify parallel map using channels (#582) 2025-07-03 05:43:10 -05:00
LevandGitHub 2ace57404b fix: properly handle utf-8 in diff highlighting (#585) 2025-07-03 05:42:40 -05:00
Dax Raad 8c4b5e088b do not install gopls if go is not installed
publish / publish (push) Has been cancelled
2025-07-02 23:59:08 -04:00
Jacob HandsandGitHub 69920a73d7 fix: use correct opencode bin path when running in development mode (#483)
publish / publish (push) Has been cancelled
2025-07-02 23:37:48 -04:00
Timo ClasenandGitHub ae76a3467a fix: typescript error (#618) 2025-07-02 23:27:43 -04:00
Adi YeroslavandGitHub 701107cda4 Update prompt reference from CLAUDE.md to AGENTS.md (#623) 2025-07-02 23:27:22 -04:00
Aiden ClineandGitHub b99565959b feat: configurable instructions (#624) 2025-07-02 23:27:04 -04:00
andrewxtandGitHub 67aa7ce04d fix mouse scroll events being interpreted as keyboard input (#628) 2025-07-02 23:26:09 -04:00
Dax Raad c663fbc3ee remove need for glibc
publish / publish (push) Has been cancelled
2025-07-02 22:53:04 -04:00
Dax Raadandopencode 2090bab537 fix(tui): change messages layout toggle keybinding from <leader>m to <leader>p
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-07-02 20:06:30 -04:00
Aiden ClineandGitHub 64d5fff9a3 fix: unawaited promise causes opencode to use unenabled formatter (#625)
publish / publish (push) Has been cancelled
2025-07-02 19:19:31 -04:00
Jay V 925f695503 docs: tweak styles 2025-07-02 18:44:05 -04:00
adamdottv f1c925795d fix: typescript error 2025-07-02 16:08:41 -05:00
adamdottv c82a060eca feat(tui): file viewer, select messages 2025-07-02 16:08:11 -05:00
63e783ef79 Changed handling of OpenRouter requests to add some custom headers so that it can see the app (#613)
Co-authored-by: Dax Raad <d@ironbay.co>
2025-07-02 14:43:59 -04:00
Dax Raad 35d6273fb3 wip: session revert/unrevert 2025-07-02 13:10:36 -04:00
Mark HugginsandGitHub b89d4a16fd fix: Copilot Premium Requests (#595) 2025-07-02 12:04:53 -04:00
Prashant ChoudharyGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2799a96032 fix: Ensure shared file previews use truncated content (#607)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-07-02 12:04:10 -04:00
Timo ClasenandGitHub 8f4b79227c fix(formatting): check for enabled formatters (#611) 2025-07-02 12:03:42 -04:00
Dax Raad c810b6d206 wip: symbols for lsp 2025-07-02 11:35:25 -04:00
Dax Raad fa35407572 fix lazy loading 2025-07-02 11:18:25 -04:00
Dax Raad 8bbbc07aff fix filewatcher not closing cleanly
publish / publish (push) Has been cancelled
2025-07-02 11:15:12 -04:00
GitHub Action 75a21ba3ce Update download stats 2025-07-02
publish / publish (push) Has been cancelled
2025-07-02 12:26:24 +00:00
Timo ClasenandGitHub 0d6fb68a88 fix(tui): no space between agent and user message (#598) 2025-07-02 05:12:49 -05:00
Jean du PlessisandGitHub 242b886434 fix: Small typo in CLI --model flag description (#577) 2025-07-02 05:10:58 -05:00
Daniel VélezandGitHub caf465a9da chore: rename OpenCode to opencode (#579) 2025-07-02 05:09:51 -05:00
Dax Raad bbf77c6139 improve ripgrep download
publish / publish (push) Has been cancelled
2025-07-01 22:39:17 -04:00
Dax Raad 53b7e04b86 ci: tweaks 2025-07-01 22:25:53 -04:00
Dax Raad 9e75e3ed18 ignore: read deleted files 2025-07-01 20:45:50 -04:00
Dax Raad 6389858d41 ignore: add file status command 2025-07-01 20:44:12 -04:00
Dax Raad 7e5941e14b ignore: add file status command 2025-07-01 20:39:43 -04:00
Dax Raad c68aeed8d9 ignore: fix file read with diff 2025-07-01 20:08:42 -04:00
Aiden ClineandGitHub b199a609a8 fix: handle null case if tool args are empty for todos (#588) 2025-07-01 18:25:23 -05:00
Frank 4a5a93b3f8 Temporarily add admin unshare api 2025-07-01 18:57:08 -04:00
Dax Raad e99bdcefac fix write tool timeout
publish / publish (push) Has been cancelled
2025-07-01 13:50:57 -04:00
Dax Raad 26dcb85de1 add file watcher 2025-07-01 13:45:25 -04:00
Dax Raad 11d042be25 snapshot functionality 2025-07-01 12:28:34 -04:00
adamdottv 33b5fe236a fix(tui): better message rendering performance
publish / publish (push) Has been cancelled
2025-07-01 07:57:45 -05:00
GitHub Action d56991006c Update download stats 2025-07-01 2025-07-01 12:27:09 +00:00
adamdottv 739a9f71c3 fix(tui): layout issues 2025-07-01 06:41:39 -05:00
aef81fce0b docs: use correct baseUrl for astro editLink (#507)
Co-authored-by: Adam Spiers <opencode@adamspiers.org>
2025-07-01 05:31:18 -05:00
Timo ClasenandGitHub 8f3d7b4038 feat: better model dialog with sorting by release date (#563) 2025-07-01 05:28:32 -05:00
Dax Raad de15e67834 fix lsp diagnostic accurancy
publish / publish (push) Has been cancelled
2025-06-30 22:48:32 -04:00
163 changed files with 16589 additions and 2576 deletions
+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)
+6
View File
@@ -4,3 +4,9 @@
| ---------- | ---------------- | --------------- | --------------- |
| 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) |
+43 -15
View File
@@ -5,7 +5,7 @@
"name": "opencode",
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6",
"sst": "3.17.8",
},
},
"packages/function": {
@@ -36,6 +36,7 @@
"env-paths": "3.0.0",
"hono": "4.7.10",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2",
"remeda": "2.22.3",
"ts-lsp-client": "1.0.3",
@@ -77,11 +78,12 @@
"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",
"solid-js": "1.9.7",
"toolbeam-docs-theme": "0.3.0",
"toolbeam-docs-theme": "0.4.1",
},
"devDependencies": {
"@types/node": "catalog:",
@@ -461,7 +463,7 @@
"@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=="],
@@ -491,6 +493,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=="],
@@ -541,6 +545,8 @@
"astro-expressive-code": ["astro-expressive-code@0.41.2", "", { "dependencies": { "rehype-expressive-code": "^0.41.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-HN0jWTnhr7mIV/2e6uu4PPRNNo/k4UEgTLZqbp3MrHU+caCARveG2yZxaZVBmxyiVdYqW5Pd3u3n2zjnshixbw=="],
"async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
@@ -599,7 +605,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=="],
@@ -633,6 +639,8 @@
"ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
"clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
@@ -669,6 +677,8 @@
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
@@ -725,6 +735,8 @@
"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=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
@@ -939,6 +951,8 @@
"ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@@ -987,6 +1001,8 @@
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isomorphic-git": ["isomorphic-git@1.32.1", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "path-browserify": "^1.0.1", "pify": "^4.0.1", "readable-stream": "^3.4.0", "sha.js": "^2.4.9", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-NZCS7qpLkCZ1M/IrujYBD31sM6pd/fMVArK4fz4I7h6m0rUW2AsYU7S7zXeABuHL6HIfW6l53b4UQ/K441CQjg=="],
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
@@ -1037,6 +1053,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=="],
@@ -1169,6 +1187,8 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minimisted": ["minimisted@2.0.1", "", { "dependencies": { "minimist": "^1.2.5" } }, "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"mri": ["mri@1.1.4", "", {}, "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w=="],
@@ -1247,7 +1267,7 @@
"pagefind": ["pagefind@1.3.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.3.0", "@pagefind/darwin-x64": "1.3.0", "@pagefind/linux-arm64": "1.3.0", "@pagefind/linux-x64": "1.3.0", "@pagefind/windows-x64": "1.3.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@@ -1257,6 +1277,8 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -1267,6 +1289,8 @@
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
"pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="],
"pino-abstract-transport": ["pino-abstract-transport@0.5.0", "", { "dependencies": { "duplexify": "^4.1.2", "split2": "^4.0.0" } }, "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ=="],
@@ -1417,6 +1441,8 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sha.js": ["sha.js@2.4.11", "", { "dependencies": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" }, "bin": { "sha.js": "./bin.js" } }, "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ=="],
"sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="],
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
@@ -1455,23 +1481,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=="],
@@ -1525,7 +1551,7 @@
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.1", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-lTI4dHZaVNQky29m7sb36Oy4tWPwxsCuFxFjF8hgGW0vpV+S6qPvI9SwsJFvdE/OHO5DoI7VMbryV1pxZHkkHQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -1793,6 +1819,8 @@
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],
+3
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: {
+3 -1
View File
@@ -5,7 +5,9 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"dev": "bun run packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"stainless": "bun run ./packages/opencode/src/index.ts serve ",
"postinstall": "./scripts/hooks"
},
"workspaces": {
@@ -21,7 +23,7 @@
},
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6"
"sst": "3.17.8"
},
"repository": {
"type": "git",
+30 -11
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> {
@@ -35,8 +36,7 @@ export class SyncServer extends DurableObject<Env> {
ws.close(code, "Durable Object is closing WebSocket")
}
async publish(secret: string, key: string, content: any) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
async publish(key: string, content: any) {
const sessionID = await this.getSessionID()
if (
!key.startsWith(`session/info/${sessionID}`) &&
@@ -76,6 +76,10 @@ export class SyncServer extends DurableObject<Env> {
.map(([key, content]) => ({ key, content }))
}
public async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
private async getSecret() {
return this.ctx.storage.get<string>("secret")
}
@@ -84,15 +88,19 @@ export class SyncServer extends DurableObject<Env> {
return this.ctx.storage.get<string>("sessionID")
}
async clear(secret: string) {
await this.assertSecret(secret)
async clear() {
const sessionID = await this.getSessionID()
const list = await this.env.Bucket.list({
prefix: `session/message/${sessionID}/`,
limit: 1000,
})
for (const item of list.objects) {
await this.env.Bucket.delete(item.key)
}
await this.env.Bucket.delete(`session/info/${sessionID}`)
await this.ctx.storage.deleteAll()
}
private async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
static shortName(id: string) {
return id.substring(id.length - 8)
}
@@ -120,7 +128,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" },
@@ -134,7 +142,17 @@ export default {
const secret = body.secret
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
const stub = env.SYNC_SERVER.get(id)
await stub.clear(secret)
await stub.assertSecret(secret)
await stub.clear()
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
}
if (request.method === "POST" && method === "share_delete_admin") {
const id = env.SYNC_SERVER.idFromName("oVF8Rsiv")
const stub = env.SYNC_SERVER.get(id)
await stub.clear()
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
@@ -150,7 +168,8 @@ export default {
const name = SyncServer.shortName(body.sessionID)
const id = env.SYNC_SERVER.idFromName(name)
const stub = env.SYNC_SERVER.get(id)
await stub.publish(body.secret, body.key, body.content)
await stub.assertSecret(body.secret)
await stub.publish(body.key, body.content)
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
-1
View File
@@ -1,4 +1,3 @@
node_modules
research
dist
gen
+1 -1
View File
@@ -49,7 +49,7 @@ else
done
if [ -z "$resolved" ]; then
printf "It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
printf "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
exit 1
fi
fi
+2 -2
View File
@@ -48,9 +48,9 @@ set "current_dir=%parent_dir%"
goto :search_loop
:not_found
echo It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the "%name%" package >&2
echo It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "%name%" package >&2
exit /b 1
:execute
rem Execute the binary with all arguments
"%resolved%" %*
"%resolved%" %*
+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": {
+1
View File
@@ -37,6 +37,7 @@
"env-paths": "3.0.0",
"hono": "4.7.10",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2",
"remeda": "2.22.3",
"ts-lsp-client": "1.0.3",
+2 -1
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`
@@ -110,6 +110,7 @@ if (!snapshot) {
return (
!lower.includes("ignore:") &&
!lower.includes("ci:") &&
!lower.includes("wip:") &&
!lower.includes("docs:") &&
!lower.includes("doc:")
)
+12 -6
View File
@@ -36,12 +36,15 @@ export namespace App {
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
}>("app")
export const use = ctx.use
const APP_JSON = "app.json"
export type Input = {
cwd: string
}
export const provideExisting = ctx.provide
export async function provide<T>(
input: Input,
cb: (app: App.Info) => Promise<T>,
@@ -96,13 +99,16 @@ export namespace App {
}
return ctx.provide(app, async () => {
const result = await cb(app.info)
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
try {
const result = await cb(app.info)
return result
} finally {
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
}
return result
})
}
+2
View File
@@ -1,5 +1,6 @@
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"
@@ -13,6 +14,7 @@ export async function bootstrap<T>(
Format.init()
ConfigHooks.init()
LSP.init()
FileWatcher.init()
return cb(app)
})
@@ -0,0 +1,37 @@
import { File } from "../../../file"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
const FileReadCommand = cmd({
command: "read <path>",
builder: (yargs) =>
yargs.positional("path", {
type: "string",
demandOption: true,
description: "File path to read",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const content = await File.read(args.path)
console.log(content)
})
},
})
const FileStatusCommand = cmd({
command: "status",
builder: (yargs) => yargs,
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const status = await File.status()
console.log(JSON.stringify(status, null, 2))
})
},
})
export const FileCommand = cmd({
command: "file",
builder: (yargs) =>
yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
async handler() {},
})
@@ -0,0 +1,28 @@
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
import { SnapshotCommand } from "./snapshot"
export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)
.command(SnapshotCommand)
.command({
command: "wait",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1_000 * 60 * 60 * 24),
)
})
},
})
.demandCommand(),
async handler() {},
})
@@ -0,0 +1,37 @@
import { LSP } from "../../../lsp"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
export const LSPCommand = cmd({
command: "lsp",
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 }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
export const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) =>
yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
})
},
})
@@ -1,34 +1,19 @@
import { App } from "../../app/app"
import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp"
import { Log } from "../../util/log"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
import { App } from "../../../app/app"
import { Ripgrep } from "../../../file/ripgrep"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const DebugCommand = cmd({
command: "debug",
export const RipgrepCommand = cmd({
command: "rg",
builder: (yargs) =>
yargs
.command(DiagnosticsCommand)
.command(TreeCommand)
.command(SymbolsCommand)
.command(FilesCommand)
.command(SearchCommand)
.demandCommand(),
async handler() {},
})
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
const TreeCommand = cmd({
command: "tree",
builder: (yargs) =>
@@ -43,20 +28,6 @@ const TreeCommand = cmd({
},
})
const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) =>
yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
})
},
})
const FilesCommand = cmd({
command: "files",
builder: (yargs) =>
@@ -86,3 +57,31 @@ const FilesCommand = cmd({
})
},
})
const SearchCommand = cmd({
command: "search <pattern>",
builder: (yargs) =>
yargs
.positional("pattern", {
type: "string",
demandOption: true,
description: "Search pattern",
})
.option("glob", {
type: "array",
description: "File glob patterns",
})
.option("limit", {
type: "number",
description: "Limit number of results",
}),
async handler(args) {
const results = await Ripgrep.search({
cwd: process.cwd(),
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
})
console.log(JSON.stringify(results, null, 2))
},
})
@@ -0,0 +1,39 @@
import { Snapshot } from "../../../snapshot"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
builder: (yargs) =>
yargs
.command(SnapshotCreateCommand)
.command(SnapshotRestoreCommand)
.demandCommand(),
async handler() {},
})
export const SnapshotCreateCommand = cmd({
command: "create",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const result = await Snapshot.create("test")
console.log(result)
})
},
})
export const SnapshotRestoreCommand = cmd({
command: "restore <commit>",
builder: (yargs) =>
yargs.positional("commit", {
type: "string",
description: "commit",
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await Snapshot.restore("test", args.commit)
console.log("restored")
})
},
})
+2 -2
View File
@@ -1,7 +1,7 @@
import { App } from "../../app/app"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { Share } from "../../share/share"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
export const ServeCommand = cmd({
@@ -23,7 +23,7 @@ export const ServeCommand = cmd({
describe: "starts a headless opencode server",
handler: async (args) => {
const cwd = process.cwd()
await App.provide({ cwd }, async () => {
await bootstrap({ cwd }, async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
+27 -1
View File
@@ -9,6 +9,7 @@ import fs from "fs/promises"
import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
import { Log } from "../../util/log"
export const TuiCommand = cmd({
command: "$0 [project]",
@@ -57,6 +58,9 @@ export const TuiCommand = cmd({
cwd = process.cwd()
cmd = [binary]
}
Log.Default.info("tui", {
cmd,
})
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
@@ -100,7 +104,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 +116,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
View File
@@ -35,6 +35,15 @@ export const UpgradeCommand = {
}
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...")
+4
View File
@@ -176,6 +176,10 @@ export namespace Config {
.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
+115
View File
@@ -1,7 +1,16 @@
import { z } from "zod"
import { Bus } from "../bus"
import { $ } from "bun"
import { createPatch } from "diff"
import path from "path"
import * as git from "isomorphic-git"
import { App } from "../app/app"
import fs from "fs"
import { Log } from "../util/log"
export namespace File {
const log = Log.create({ service: "file" })
export const Event = {
Edited: Bus.event(
"file.edited",
@@ -10,4 +19,110 @@ export namespace File {
}),
),
}
export async function status() {
const app = App.info()
if (!app.git) return []
const diffOutput = await $`git diff --numstat HEAD`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
const changedFiles = []
if (diffOutput.trim()) {
const lines = diffOutput.trim().split("\n")
for (const line of lines) {
const [added, removed, filepath] = line.split("\t")
changedFiles.push({
file: filepath,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
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 lines = content.split("\n").length
changedFiles.push({
file: filepath,
added: lines,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
// Get deleted files
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
for (const filepath of deletedFiles) {
changedFiles.push({
file: filepath,
added: 0,
removed: 0, // Could get original line count but would require another git command
status: "deleted",
})
}
}
return changedFiles.map((x) => ({
...x,
file: path.relative(app.path.cwd, path.join(app.path.root, x.file)),
}))
}
export async function read(file: string) {
using _ = log.time("read", { file })
const app = App.info()
const full = path.join(app.path.cwd, file)
const content = await Bun.file(full)
.text()
.catch(() => "")
.then((x) => x.trim())
if (app.git) {
const rel = path.relative(app.path.root, full)
const diff = await git.status({
fs,
dir: app.path.root,
filepath: rel,
})
if (diff !== "unmodified") {
const original = await $`git show HEAD:${rel}`
.cwd(app.path.root)
.quiet()
.nothrow()
.text()
const patch = createPatch(file, original, content, "old", "new", {
context: Infinity,
})
return { type: "patch", content: patch }
}
}
return { type: "raw", content }
}
}
+134 -13
View File
@@ -1,3 +1,4 @@
// Ripgrep utility functions
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
@@ -8,10 +9,91 @@ import { $ } from "bun"
import { Fzf } from "./fzf"
export namespace Ripgrep {
const Stats = z.object({
elapsed: z.object({
secs: z.number(),
nanos: z.number(),
human: z.string(),
}),
searches: z.number(),
searches_with_match: z.number(),
bytes_searched: z.number(),
bytes_printed: z.number(),
matched_lines: z.number(),
matches: z.number(),
})
const Begin = z.object({
type: z.literal("begin"),
data: z.object({
path: z.object({
text: z.string(),
}),
}),
})
export const Match = z.object({
type: z.literal("match"),
data: z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
}),
),
}),
})
const End = z.object({
type: z.literal("end"),
data: z.object({
path: z.object({
text: z.string(),
}),
binary_offset: z.number().nullable(),
stats: Stats,
}),
})
const Summary = z.object({
type: z.literal("summary"),
data: z.object({
elapsed_total: z.object({
human: z.string(),
nanos: z.number(),
secs: z.number(),
}),
stats: Stats,
}),
})
const Result = z.union([Begin, Match, End, Summary])
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
const PLATFORM = {
darwin: { platform: "apple-darwin", extension: "tar.gz" },
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
win32: { platform: "pc-windows-msvc", extension: "zip" },
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": {
platform: "aarch64-unknown-linux-gnu",
extension: "tar.gz",
},
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
export const ExtractionFailedError = NamedError.create(
@@ -47,15 +129,13 @@ export namespace Ripgrep {
const file = Bun.file(filepath)
if (!(await file.exists())) {
const archMap = { x64: "x86_64", arm64: "aarch64" } as const
const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
const platformKey =
`${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
const version = "14.1.1"
const filename = `ripgrep-${version}-${arch}-${config.platform}.${config.extension}`
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
@@ -68,8 +148,8 @@ export namespace Ripgrep {
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
if (process.platform === "darwin") args.push("--include=*/rg")
if (process.platform === "linux") args.push("--wildcards", "*/rg")
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, {
cwd: Global.Path.bin,
@@ -100,7 +180,7 @@ export namespace Ripgrep {
})
}
await fs.unlink(archivePath)
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
}
return {
@@ -229,4 +309,45 @@ 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/*'",
]
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push(input.pattern)
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
return []
}
const lines = result.text().trim().split("\n").filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((r) => r.type === "match")
.map((r) => r.data)
}
}
+3
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()
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod"
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" })
export const Event = {
Updated: Bus.event(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("rename"), z.literal("change")]),
}),
),
}
const state = 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()
},
)
export function init() {
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 -1
View File
@@ -31,7 +31,7 @@ export namespace Format {
const result = []
for (const item of Object.values(Formatter)) {
if (!item.extensions.includes(ext)) continue
if (!isEnabled(item)) continue
if (!(await isEnabled(item))) continue
result.push(item)
}
return result
+40 -41
View File
@@ -12,6 +12,7 @@ import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "../util/error"
import { withTimeout } from "../util/timeout"
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
@@ -52,7 +53,9 @@ export namespace LSPClient {
log.info("textDocument/publishDiagnostics", {
path,
})
const exists = diagnostics.has(path)
diagnostics.set(path, params.diagnostics)
if (!exists && serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID })
})
connection.onRequest("workspace/configuration", async () => {
@@ -61,8 +64,9 @@ export namespace LSPClient {
connection.listen()
log.info("sending initialize", { id: serverID })
await Promise.race([
await withTimeout(
connection.sendRequest("initialize", {
rootUri: "file://" + app.path.cwd,
processId: server.process.pid,
workspaceFolders: [
{
@@ -88,14 +92,21 @@ export namespace LSPClient {
},
},
}),
new Promise((_, reject) => {
setTimeout(() => {
reject(new InitializeError({ serverID }))
}, 5_000)
}),
])
5_000,
).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
@@ -116,36 +127,28 @@ export namespace LSPClient {
const file = Bun.file(input.path)
const text = await file.text()
const version = files[input.path]
if (version === undefined) {
log.info("textDocument/didOpen", input)
if (version !== undefined) {
diagnostics.delete(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
await connection.sendNotification("textDocument/didClose", {
textDocument: {
uri: `file://` + input.path,
languageId,
version: 0,
text,
},
})
files[input.path] = 0
return
}
log.info("textDocument/didChange", input)
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didChange", {
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: `file://` + input.path,
version: ++files[input.path],
languageId,
version: 0,
text,
},
contentChanges: [
{
text,
},
],
})
files[input.path] = 0
return
},
},
get diagnostics() {
@@ -157,35 +160,31 @@ export namespace LSPClient {
: path.resolve(app.path.cwd, input.path)
log.info("waiting for diagnostics", input)
let unsub: () => void
let timeout: NodeJS.Timeout
return await Promise.race([
new Promise<void>(async (resolve) => {
return await withTimeout(
new Promise<void>((resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.serverID
) {
log.info("got diagnostics", input)
clearTimeout(timeout)
unsub?.()
resolve()
}
})
}),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
log.info("timed out refreshing diagnostics", input)
unsub?.()
resolve()
}, 5000)
}),
])
3000,
)
.catch(() => {})
.finally(() => {
unsub?.()
})
},
async shutdown() {
log.info("shutting down")
log.info("shutting down", { serverID })
connection.end()
connection.dispose()
server.process.kill("SIGTERM")
log.info("shutdown", { serverID })
},
}
+56 -43
View File
@@ -4,19 +4,59 @@ import { LSPClient } from "./client"
import path from "path"
import { LSPServer } from "./server"
import { Ripgrep } from "../file/ripgrep"
import { z } from "zod"
export namespace LSP {
const log = Log.create({ service: "lsp" })
export const Symbol = z
.object({
name: z.string(),
kind: z.number(),
location: z.object({
uri: z.string(),
range: z.object({
start: z.object({
line: z.number(),
character: z.number(),
}),
end: z.object({
line: z.number(),
character: z.number(),
}),
}),
}),
})
.openapi({
ref: "LSP.Symbol",
})
export type Symbol = z.infer<typeof Symbol>
const state = App.state(
"lsp",
async () => {
async (app) => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
const skip = new Set<string>()
for (const server of Object.values(LSPServer)) {
for (const extension of server.extensions) {
const [file] = await Ripgrep.files({
cwd: app.path.cwd,
glob: "*" + extension,
})
if (!file) continue
const handle = await server.spawn(App.info())
if (!handle) break
const client = await LSPClient.create(server.id, handle).catch(
(err) => log.error("", { error: err }),
)
if (!client) break
clients.set(server.id, client)
break
}
}
log.info("initialized")
return {
clients,
skip,
}
},
async (state) => {
@@ -27,49 +67,22 @@ export namespace LSP {
)
export async function init() {
log.info("init")
const app = App.info()
const result = Object.values(LSPServer).map(async (x) => {
for (const extension of x.extensions) {
const [file] = await Ripgrep.files({
cwd: app.path.cwd,
glob: "*" + extension,
})
if (!file) continue
await LSP.touchFile(file, true)
break
}
})
return Promise.all(result)
return state()
}
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
const extension = path.parse(input).ext
const s = await state()
const matches = Object.values(LSPServer).filter((x) =>
x.extensions.includes(extension),
)
for (const match of matches) {
const existing = s.clients.get(match.id)
if (existing) continue
if (s.skip.has(match.id)) continue
s.skip.add(match.id)
const handle = await match.spawn(App.info())
if (!handle) continue
const client = await LSPClient.create(match.id, handle).catch(() => {})
if (!client) {
s.skip.add(match.id)
continue
}
s.clients.set(match.id, client)
}
if (waitForDiagnostics) {
await run(async (client) => {
const wait = client.waitForDiagnostics({ path: input })
await client.notify.open({ path: input })
return wait
})
}
const matches = Object.values(LSPServer)
.filter((x) => x.extensions.includes(extension))
.map((x) => x.id)
await run(async (client) => {
if (!matches.includes(client.serverID)) return
const wait = waitForDiagnostics
? client.waitForDiagnostics({ path: input })
: Promise.resolve()
await client.notify.open({ path: input })
return wait
})
}
export async function diagnostics() {
@@ -107,7 +120,7 @@ export namespace LSP {
client.connection.sendRequest("workspace/symbol", {
query,
}),
)
).then((result) => result.flat() as LSP.Symbol[])
}
async function run<T>(
+59
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" })
@@ -57,6 +59,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"],
@@ -143,4 +146,60 @@ export namespace LSPServer {
}
},
}
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),
}
},
}
}
@@ -99,11 +99,25 @@ export namespace Provider {
})
info.access = tokens.access
}
let isAgentCall = false
try {
const body =
typeof init.body === "string"
? JSON.parse(init.body)
: init.body
if (body?.messages) {
isAgentCall = body.messages.some(
(msg: any) =>
msg.role && ["tool", "assistant"].includes(msg.role),
)
}
} catch {}
const headers = {
...init.headers,
...copilot.HEADERS,
Authorization: `Bearer ${info.access}`,
"Openai-Intent": "conversation-edits",
"X-Initiator": isAgentCall ? "agent" : "user",
}
delete headers["x-api-key"]
return fetch(input, {
@@ -191,6 +205,17 @@ export namespace Provider {
},
}
},
openrouter: async () => {
return {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
},
}
},
}
const state = App.state("provider", async () => {
@@ -17,6 +17,9 @@ export namespace ProviderTransform {
anthropic: {
cacheControl: { type: "ephemeral" },
},
openaiCompatible: {
cache_control: { type: "ephemeral" },
},
}
}
}
+130 -4
View File
@@ -14,6 +14,8 @@ import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
const ERRORS = {
400: {
@@ -73,7 +75,7 @@ export namespace Server {
documentation: {
info: {
title: "opencode",
version: "0.0.2",
version: "0.0.3",
description: "opencode api",
},
openapi: "3.0.0",
@@ -492,12 +494,44 @@ export namespace Server {
},
)
.get(
"/file",
"/find",
describeRoute({
description: "Search for files",
description: "Find text in files",
responses: {
200: {
description: "Search for files",
description: "Matches",
content: {
"application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
pattern: z.string(),
}),
),
async (c) => {
const app = App.info()
const pattern = c.req.valid("query").pattern
const result = await Ripgrep.search({
cwd: app.path.cwd,
pattern,
limit: 10,
})
return c.json(result)
},
)
.get(
"/find/file",
describeRoute({
description: "Find files",
responses: {
200: {
description: "File paths",
content: {
"application/json": {
schema: resolver(z.string().array()),
@@ -523,6 +557,98 @@ export namespace Server {
return c.json(result)
},
)
.get(
"/find/symbol",
describeRoute({
description: "Find workspace symbols",
responses: {
200: {
description: "Symbols",
content: {
"application/json": {
schema: resolver(z.unknown().array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
query: z.string(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
},
)
.get(
"/file",
describeRoute({
description: "Read a file",
responses: {
200: {
description: "File content",
content: {
"application/json": {
schema: resolver(
z.object({
type: z.enum(["raw", "patch"]),
content: z.string(),
}),
),
},
},
},
},
}),
zValidator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
log.info("read file", {
path,
content: content.content,
})
return c.json(content)
},
)
.get(
"/file/status",
describeRoute({
description: "Get file status",
responses: {
200: {
description: "File status",
content: {
"application/json": {
schema: resolver(
z
.object({
file: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.array(),
),
},
},
},
},
}),
async (c) => {
const content = await File.status()
return c.json(content)
},
)
return result
}
+187 -32
View File
@@ -1,39 +1,42 @@
import path from "path"
import { App } from "../app/app"
import { Identifier } from "../id/id"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
import path from "node:path"
import { Decimal } from "decimal.js"
import { z, ZodSchema } from "zod"
import {
generateText,
LoadAPIKeyError,
convertToCoreMessages,
streamText,
tool,
wrapLanguageModel,
type Tool as AITool,
type LanguageModelUsage,
type CoreMessage,
type UIMessage,
type ProviderMetadata,
wrapLanguageModel,
type Attachment,
} from "ai"
import { z, ZodSchema } from "zod"
import { Decimal } from "decimal.js"
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
import { Share } from "../share/share"
import { Message } from "./message"
import { App } from "../app/app"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { MCP } from "../mcp"
import { NamedError } from "../util/error"
import type { Tool } from "../tool/tool"
import { SystemPrompt } from "./system"
import { Flag } from "../flag/flag"
import type { ModelsDev } from "../provider/models"
import { Installation } from "../installation"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import { MCP } from "../mcp"
import { Provider } from "../provider/provider"
import { ProviderTransform } from "../provider/transform"
import type { ModelsDev } from "../provider/models"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
import { Storage } from "../storage/storage"
import type { Tool } from "../tool/tool"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
import { Message } from "./message"
import { SystemPrompt } from "./system"
import { FileTime } from "../file/time"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -53,6 +56,13 @@ export namespace Session {
created: z.number(),
updated: z.number(),
}),
revert: z
.object({
messageID: z.string(),
part: z.number(),
snapshot: z.string().optional(),
})
.optional(),
})
.openapi({
ref: "Session",
@@ -177,11 +187,13 @@ export namespace Session {
}
export async function unshare(id: string) {
const share = await getShare(id)
if (!share) return
await Storage.remove("session/share/" + id)
await update(id, (draft) => {
draft.share = undefined
})
await Share.remove(id)
await Share.remove(id, share.secret)
}
export async function update(id: string, editor: (session: Info) => void) {
@@ -285,6 +297,37 @@ export namespace Session {
l.info("chatting")
const model = await Provider.getModel(input.providerID, input.modelID)
let msgs = await messages(input.sessionID)
const session = await get(input.sessionID)
if (session.revert) {
const trimmed = []
for (const msg of msgs) {
if (
msg.id > session.revert.messageID ||
(msg.id === session.revert.messageID && session.revert.part === 0)
) {
await Storage.remove(
"session/message/" + input.sessionID + "/" + msg.id,
)
await Bus.publish(Message.Event.Removed, {
sessionID: input.sessionID,
messageID: msg.id,
})
continue
}
if (msg.id === session.revert.messageID) {
if (session.revert.part === 0) break
msg.parts = msg.parts.slice(0, session.revert.part)
}
trimmed.push(msg)
}
msgs = trimmed
await update(input.sessionID, (draft) => {
draft.revert = undefined
})
}
const previous = msgs.at(-1)
// auto summarize if too long
@@ -319,7 +362,60 @@ export namespace Session {
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
const app = App.info()
const session = await get(input.sessionID)
input.parts = await Promise.all(
input.parts.map(async (part): Promise<Message.MessagePart[]> => {
if (part.type === "file") {
const url = new URL(part.url)
switch (url.protocol) {
case "file:":
const filepath = path.join(app.path.cwd, url.pathname)
let file = Bun.file(filepath)
if (part.mediaType === "text/plain") {
let text = await file.text()
const range = {
start: url.searchParams.get("start"),
end: url.searchParams.get("end"),
}
if (range.start != null && part.mediaType === "text/plain") {
const lines = text.split("\n")
const start = parseInt(range.start)
const end = range.end ? parseInt(range.end) : lines.length
text = lines.slice(start, end).join("\n")
}
FileTime.read(input.sessionID, filepath)
return [
{
type: "text",
text: [
"Called the Read tool on " + url.pathname,
"<results>",
text,
"</results>",
].join("\n"),
},
]
}
return [
{
type: "text",
text: ["Called the Read tool on " + url.pathname].join("\n"),
},
{
type: "file",
url:
`data:${part.mediaType};base64,` +
Buffer.from(await file.bytes()).toString("base64url"),
mediaType: part.mediaType,
filename: part.filename!,
},
]
}
}
return [part]
}),
).then((x) => x.flat())
if (msgs.length === 0 && !session.parentID) {
generateText({
maxTokens: input.providerID === "google" ? 1024 : 20,
@@ -335,7 +431,7 @@ export namespace Session {
{
role: "user",
content: "",
parts: toParts(input.parts),
parts: toParts(input.parts).parts,
},
]),
],
@@ -349,6 +445,7 @@ export namespace Session {
})
.catch(() => {})
}
const snapshot = await Snapshot.create(input.sessionID)
const msg: Message.Info = {
role: "user",
id: Identifier.ascending("message"),
@@ -359,6 +456,7 @@ export namespace Session {
},
sessionID: input.sessionID,
tool: {},
snapshot,
},
}
await updateMessage(msg)
@@ -373,6 +471,7 @@ export namespace Session {
role: "assistant",
parts: [],
metadata: {
snapshot,
assistant: {
system,
path: {
@@ -424,6 +523,7 @@ export namespace Session {
})
next.metadata!.tool![opts.toolCallId] = {
...result.metadata,
snapshot: await Snapshot.create(input.sessionID),
time: {
start,
end: Date.now(),
@@ -436,6 +536,7 @@ export namespace Session {
error: true,
message: e.toString(),
title: e.toString(),
snapshot: await Snapshot.create(input.sessionID),
time: {
start,
end: Date.now(),
@@ -457,6 +558,7 @@ export namespace Session {
const result = await execute(args, opts)
next.metadata!.tool![opts.toolCallId] = {
...result.metadata,
snapshot: await Snapshot.create(input.sessionID),
time: {
start,
end: Date.now(),
@@ -471,6 +573,7 @@ export namespace Session {
next.metadata!.tool![opts.toolCallId] = {
error: true,
message: e.toString(),
snapshot: await Snapshot.create(input.sessionID),
title: "mcp",
time: {
start,
@@ -538,6 +641,7 @@ export namespace Session {
// return step
// },
toolCallStreaming: true,
maxRetries: 10,
maxTokens: Math.max(0, model.info.limit.output) || undefined,
abortSignal: abort.signal,
maxSteps: 1000,
@@ -735,6 +839,51 @@ export namespace Session {
return next
}
export async function revert(input: {
sessionID: string
messageID: string
part: number
}) {
const message = await getMessage(input.sessionID, input.messageID)
if (!message) return
const part = message.parts[input.part]
if (!part) return
const session = await get(input.sessionID)
const snapshot =
session.revert?.snapshot ?? (await Snapshot.create(input.sessionID))
const old = (() => {
if (message.role === "assistant") {
const lastTool = message.parts.findLast(
(part, index) =>
part.type === "tool-invocation" && index < input.part,
)
if (lastTool && lastTool.type === "tool-invocation")
return message.metadata.tool[lastTool.toolInvocation.toolCallId]
.snapshot
}
return message.metadata.snapshot
})()
if (old) await Snapshot.restore(input.sessionID, old)
await update(input.sessionID, (draft) => {
draft.revert = {
messageID: input.messageID,
part: input.part,
snapshot,
}
})
}
export async function unrevert(sessionID: string) {
const session = await get(sessionID)
if (!session) return
if (!session.revert) return
if (session.revert.snapshot)
await Snapshot.restore(sessionID, session.revert.snapshot)
update(sessionID, (draft) => {
draft.revert = undefined
})
}
export async function summarize(input: {
sessionID: string
providerID: string
@@ -934,7 +1083,7 @@ function toUIMessage(msg: Message.Info): UIMessage {
id: msg.id,
role: "assistant",
content: "",
parts: toParts(msg.parts),
...toParts(msg.parts),
}
}
@@ -943,35 +1092,41 @@ function toUIMessage(msg: Message.Info): UIMessage {
id: msg.id,
role: "user",
content: "",
parts: toParts(msg.parts),
...toParts(msg.parts),
}
}
throw new Error("not implemented")
}
function toParts(parts: Message.MessagePart[]): UIMessage["parts"] {
const result: UIMessage["parts"] = []
function toParts(parts: Message.MessagePart[]) {
const result: {
parts: UIMessage["parts"]
experimental_attachments: Attachment[]
} = {
parts: [],
experimental_attachments: [],
}
for (const part of parts) {
switch (part.type) {
case "text":
result.push({ type: "text", text: part.text })
result.parts.push({ type: "text", text: part.text })
break
case "file":
result.push({
type: "file",
data: part.url,
mimeType: part.mediaType,
result.experimental_attachments.push({
url: part.url,
contentType: part.mediaType,
name: part.filename,
})
break
case "tool-invocation":
result.push({
result.parts.push({
type: "tool-invocation",
toolInvocation: part.toolInvocation,
})
break
case "step-start":
result.push({
result.parts.push({
type: "step-start",
})
break
+9
View File
@@ -159,6 +159,7 @@ export namespace Message {
z
.object({
title: z.string(),
snapshot: z.string().optional(),
time: z.object({
start: z.number(),
end: z.number(),
@@ -188,6 +189,7 @@ export namespace Message {
}),
})
.optional(),
snapshot: z.string().optional(),
})
.openapi({ ref: "MessageMetadata" }),
})
@@ -203,6 +205,13 @@ export namespace Message {
info: Info,
}),
),
Removed: Bus.event(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
}),
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({
@@ -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))
}
+2 -2
View File
@@ -66,10 +66,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())
}
}
+67
View File
@@ -0,0 +1,67 @@
import { App } from "../app/app"
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
return
log.info("creating snapshot")
const app = App.info()
const git = gitdir(sessionID)
// not a git repo, check if too big to snapshot
if (!app.git) {
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: 1000,
})
log.info("found files", { count: files.length })
if (files.length > 1000) return
}
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()
const git = gitdir(sessionID)
await $`git --git-dir=${git} checkout ${commit} --force`
.quiet()
.cwd(app.path.root)
}
function gitdir(sessionID: string) {
const app = App.info()
return path.join(app.path.data, "snapshot", sessionID)
}
}
-21
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
@@ -45,8 +26,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],
+4 -4
View File
@@ -489,10 +489,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)
+1 -1
View File
@@ -89,7 +89,7 @@ export const ReadTool = Tool.define({
output += "\n</file>"
// just warms the lsp client
await LSP.touchFile(filePath, true)
await LSP.touchFile(filePath, false)
FileTime.read(ctx.sessionID, filePath)
return {
+1 -1
View File
@@ -7,7 +7,7 @@ Usage:
- 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
- Results are returned using cat -n format, with line numbers starting at 1
- This tool allows OpenCode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as OpenCode is a multimodal LLM.
- This tool allows opencode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as opencode is a multimodal LLM.
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
+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
}
}
+1
View File
@@ -4,6 +4,7 @@ export function lazy<T>(fn: () => T) {
return (): T => {
if (loaded) return value as T
loaded = true
value = fn()
return value as T
}
+14
View File
@@ -0,0 +1,14 @@
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timeout: NodeJS.Timeout
return Promise.race([
promise.then((result) => {
clearTimeout(timeout)
return result
}),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`Operation timed out after ${ms}ms`))
}, ms)
}),
])
}
+4 -1
View File
@@ -15,11 +15,13 @@ require (
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.7
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
github.com/tidwall/gjson v1.14.4
rsc.io/qr v0.2.0
)
replace github.com/sst/opencode-sdk-go => ./sdk
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
require (
@@ -35,6 +37,7 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
+2 -2
View File
@@ -92,6 +92,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -181,8 +183,6 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sst/opencode-sdk-go v0.1.0-alpha.7 h1:trfzTMn9o/h2fxE4z+BtJPZvCTdVHjwgXnAH/rTAx0I=
github.com/sst/opencode-sdk-go v0.1.0-alpha.7/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+71 -32
View File
@@ -20,9 +20,6 @@ import (
"github.com/sst/opencode/internal/util"
)
var RootPath string
var CwdPath string
type App struct {
Info opencode.App
Version string
@@ -38,6 +35,7 @@ type App struct {
}
type SessionSelectedMsg = *opencode.Session
type SessionLoadedMsg struct{}
type ModelSelectedMsg struct {
Provider opencode.Provider
Model opencode.Model
@@ -46,14 +44,14 @@ 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
}
type FileRenderedMsg struct {
FilePath string
}
func New(
ctx context.Context,
@@ -61,8 +59,8 @@ func New(
appInfo opencode.App,
httpClient *opencode.Client,
) (*App, error) {
RootPath = appInfo.Path.Root
CwdPath = appInfo.Path.Cwd
util.RootPath = appInfo.Path.Root
util.CwdPath = appInfo.Path.Cwd
configInfo, err := httpClient.Config.Get(ctx)
if err != nil {
@@ -125,6 +123,23 @@ func New(
return app, nil
}
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
command := a.Commands[commandName]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = a.Config.Keybinds.Leader + " " + kb.Key
}
return base(key) + muted(" "+command.Description)
}
func (a *App) InitializeProvider() tea.Cmd {
return func() tea.Msg {
providersResponse, err := a.Client.Config.Providers(context.Background())
@@ -187,7 +202,10 @@ func (a *App) InitializeProvider() tea.Cmd {
}
}
func getDefaultModel(response *opencode.ConfigProvidersResponse, provider opencode.Provider) *opencode.Model {
func getDefaultModel(
response *opencode.ConfigProvidersResponse,
provider opencode.Provider,
) *opencode.Model {
if match, ok := response.Default[provider.ID]; ok {
model := provider.Models[match]
return &model
@@ -199,13 +217,6 @@ 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
@@ -278,24 +289,40 @@ 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)))
}
optimisticParts := []opencode.MessagePart{{
Type: opencode.MessagePartTypeText,
Text: text,
}}
if len(attachments) > 0 {
for _, attachment := range attachments {
optimisticParts = append(optimisticParts, opencode.MessagePart{
Type: opencode.MessagePartTypeFile,
Filename: attachment.Filename.Value,
MediaType: attachment.MediaType.Value,
URL: attachment.URL.Value,
})
}
}
optimisticMessage := opencode.Message{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.MessageRoleUser,
Parts: []opencode.MessagePart{{
Type: opencode.MessagePartTypeText,
Text: text,
}},
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.MessageRoleUser,
Parts: optimisticParts,
Metadata: opencode.MessageMetadata{
SessionID: a.Session.ID,
Time: opencode.MessageMetadataTime{
@@ -308,13 +335,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.MessagePartUnionParam{
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
},
}
if len(attachments) > 0 {
for _, attachment := range attachments {
parts = append(parts, opencode.FilePartParam{
MediaType: attachment.MediaType,
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),
})
@@ -328,7 +367,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 {
+52 -14
View File
@@ -75,18 +75,21 @@ 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"
ModelListCommand CommandName = "model_list"
ThemeListCommand CommandName = "theme_list"
FileListCommand CommandName = "file_list"
FileCloseCommand CommandName = "file_close"
FileSearchCommand CommandName = "file_search"
FileDiffToggleCommand CommandName = "file_diff_toggle"
ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
HistoryPreviousCommand CommandName = "history_previous"
HistoryNextCommand CommandName = "history_next"
MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
@@ -95,6 +98,9 @@ const (
MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last"
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
MessagesCopyCommand CommandName = "messages_copy"
MessagesRevertCommand CommandName = "messages_revert"
AppExitCommand CommandName = "app_exit"
)
@@ -155,6 +161,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>s"),
Trigger: "share",
},
{
Name: SessionUnshareCommand,
Description: "unshare session",
Keybindings: parseBindings("<leader>u"),
Trigger: "unshare",
},
{
Name: SessionInterruptCommand,
Description: "interrupt session",
@@ -184,6 +196,27 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>t"),
Trigger: "themes",
},
{
Name: FileListCommand,
Description: "list files",
Keybindings: parseBindings("<leader>f"),
Trigger: "files",
},
{
Name: FileCloseCommand,
Description: "close file",
Keybindings: parseBindings("esc"),
},
{
Name: FileSearchCommand,
Description: "search file",
Keybindings: parseBindings("<leader>/"),
},
{
Name: FileDiffToggleCommand,
Description: "split/unified diff",
Keybindings: parseBindings("<leader>v"),
},
{
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
@@ -210,16 +243,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Description: "insert newline",
Keybindings: parseBindings("shift+enter", "ctrl+j"),
},
// {
// Name: HistoryPreviousCommand,
// Description: "previous prompt",
// Keybindings: parseBindings("up"),
// },
// {
// Name: HistoryNextCommand,
// Description: "next prompt",
// Keybindings: parseBindings("down"),
// },
{
Name: MessagesPageUpCommand,
Description: "page up",
@@ -243,12 +266,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
{
Name: MessagesPreviousCommand,
Description: "previous message",
Keybindings: parseBindings("ctrl+alt+k"),
Keybindings: parseBindings("ctrl+up"),
},
{
Name: MessagesNextCommand,
Description: "next message",
Keybindings: parseBindings("ctrl+alt+j"),
Keybindings: parseBindings("ctrl+down"),
},
{
Name: MessagesFirstCommand,
@@ -260,6 +283,21 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"),
},
{
Name: MessagesLayoutToggleCommand,
Description: "toggle layout",
Keybindings: parseBindings("<leader>p"),
},
{
Name: MessagesCopyCommand,
Description: "copy message",
Keybindings: parseBindings("<leader>y"),
},
{
Name: MessagesRevertCommand,
Description: "revert message",
Keybindings: parseBindings("<leader>r"),
},
{
Name: AppExitCommand,
Description: "exit the app",
@@ -25,13 +25,6 @@ func (c *CommandCompletionProvider) GetId() string {
return "commands"
}
func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Commands",
Value: "commands",
})
}
func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands"
}
@@ -2,64 +2,114 @@ package completions
import (
"context"
"log/slog"
"sort"
"strconv"
"strings"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type filesAndFoldersContextGroup struct {
app *app.App
prefix string
app *app.App
gitFiles []dialog.CompletionItemI
}
func (cg *filesAndFoldersContextGroup) GetId() string {
return cg.prefix
}
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Files & Folders",
Value: "files",
})
return "files"
}
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
return "no matching files"
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
files, err := cg.app.Client.File.Search(
context.Background(),
opencode.FileSearchParams{Query: opencode.F(query)},
)
if err != nil {
return []string{}, err
func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
t := theme.CurrentTheme()
items := make([]dialog.CompletionItemI, 0)
base := styles.NewStyle().Background(t.BackgroundElement())
green := base.Foreground(t.Success()).Render
red := base.Foreground(t.Error()).Render
status, _ := cg.app.Client.File.Status(context.Background())
if status != nil {
files := *status
sort.Slice(files, func(i, j int) bool {
return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed
})
for _, file := range files {
title := file.File
if file.Added > 0 {
title += green(" +" + strconv.Itoa(int(file.Added)))
}
if file.Removed > 0 {
title += red(" -" + strconv.Itoa(int(file.Removed)))
}
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: file.File,
})
items = append(items, item)
}
}
return *files, nil
return items
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
matches, err := cg.getFiles(query)
if err != nil {
return nil, err
func (cg *filesAndFoldersContextGroup) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
items := make([]dialog.CompletionItemI, 0)
query = strings.TrimSpace(query)
if query == "" {
items = append(items, cg.gitFiles...)
}
items := make([]dialog.CompletionItemI, 0, len(matches))
for _, file := range matches {
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: file,
Value: file,
})
items = append(items, item)
files, err := cg.app.Client.Find.Files(
context.Background(),
opencode.FindFilesParams{Query: opencode.F(query)},
)
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 {
exists := false
for _, existing := range cg.gitFiles {
if existing.GetValue() == file {
if query != "" {
items = append(items, existing)
}
exists = true
}
}
if !exists {
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: file,
Value: file,
})
items = append(items, item)
}
}
return items, nil
}
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
return &filesAndFoldersContextGroup{
app: app,
prefix: "file",
cg := &filesAndFoldersContextGroup{
app: app,
}
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"]
}
+108 -107
View File
@@ -3,17 +3,19 @@ package chat
import (
"fmt"
"log/slog"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/google/uuid"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -21,9 +23,8 @@ import (
type EditorComponent interface {
tea.Model
tea.ViewModel
SetSize(width, height int) tea.Cmd
Content() string
View(width int) string
Content(width int) string
Lines() int
Value() string
Focused() bool
@@ -33,19 +34,12 @@ type EditorComponent interface {
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd)
SetInterruptKeyInDebounce(inDebounce bool)
}
type editorComponent struct {
app *app.App
width, height int
textarea textarea.Model
attachments []app.Attachment
history []string
historyIndex int
currentMessage string
spinner spinner.Model
interruptKeyInDebounce bool
}
@@ -74,17 +68,54 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg:
if msg.IsCommand {
switch msg.ProviderID {
case "commands":
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
return m, tea.Batch(cmds...)
} else {
existingValue := m.textarea.Value()
case "files":
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.CompletionValue + " ")
return m, nil
}
// Replace the current token (after last space)
// The range to replace is from the '@' up to the current cursor position.
// Replace the search term (e.g., "@search") with an empty string first.
cursorCol := m.textarea.CursorColumn()
m.textarea.ReplaceRange(atIndex, cursorCol, "")
// Now, insert the attachment at the position where the '@' was.
// The cursor is now at `atIndex` after the replacement.
filePath := msg.CompletionValue
extension := filepath.Ext(filePath)
mediaType := ""
switch extension {
case ".jpg":
mediaType = "image/jpeg"
case ".png", ".jpeg", ".gif", ".webp":
mediaType = "image/" + extension[1:]
case ".pdf":
mediaType = "application/pdf"
default:
mediaType = "text/plain"
}
attachment := &textarea.Attachment{
ID: uuid.NewString(),
Display: "@" + filePath,
URL: fmt.Sprintf("file://./%s", filePath),
Filename: filePath,
MediaType: mediaType,
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
default:
existingValue := m.textarea.Value()
lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 {
m.textarea.SetValue(msg.CompletionValue + " ")
@@ -105,7 +136,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Content() string {
func (m *editorComponent) Content(width int) string {
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
@@ -114,6 +145,7 @@ func (m *editorComponent) Content() string {
Bold(true)
prompt := promptStyle.Render(">")
m.textarea.SetWidth(width - 6)
textarea := lipgloss.JoinHorizontal(
lipgloss.Top,
prompt,
@@ -121,7 +153,7 @@ func (m *editorComponent) Content() string {
)
textarea = styles.NewStyle().
Background(t.BackgroundElement()).
Width(m.width).
Width(width).
PaddingTop(1).
PaddingBottom(1).
BorderStyle(lipgloss.ThickBorder()).
@@ -135,7 +167,15 @@ func (m *editorComponent) Content() 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")
}
@@ -146,7 +186,7 @@ func (m *editorComponent) Content() string {
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
}
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
info := hint + spacer + model
@@ -156,19 +196,18 @@ func (m *editorComponent) Content() string {
return content
}
func (m *editorComponent) View() string {
func (m *editorComponent) View(width int) string {
if m.Lines() > 1 {
t := theme.CurrentTheme()
return lipgloss.Place(
m.width,
m.height,
width,
5,
lipgloss.Center,
lipgloss.Center,
"",
styles.WhitespaceStyle(t.Background()),
styles.WhitespaceStyle(theme.CurrentTheme().Background()),
)
}
return m.Content()
return m.Content(width)
}
func (m *editorComponent) Focused() bool {
@@ -183,16 +222,6 @@ func (m *editorComponent) Blur() {
m.textarea.Blur()
}
func (m *editorComponent) GetSize() (width, height int) {
return m.width, m.height
}
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
return nil
}
func (m *editorComponent) Lines() int {
return m.textarea.LineCount()
}
@@ -208,29 +237,29 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
}
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
m.textarea.ReplaceRange(len(value)-1, len(value), "")
m.textarea.InsertString("\n")
return m, nil
}
var cmds []tea.Cmd
attachments := m.textarea.GetAttachments()
fileParts := make([]opencode.FilePartParam, 0)
for _, attachment := range attachments {
fileParts = append(fileParts, opencode.FilePartParam{
Type: opencode.F(opencode.FilePartTypeFile),
MediaType: 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
// Save to history if not empty and not a duplicate of the last entry
if value != "" {
if len(m.history) == 0 || m.history[len(m.history)-1] != value {
m.history = append(m.history, value)
}
m.historyIndex = len(m.history)
m.currentMessage = ""
}
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...)
}
@@ -240,18 +269,23 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
}
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
imageBytes, text, err := image.GetImageFromClipboard()
_, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, nil
}
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
// if len(imageBytes) != 0 {
// attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
// attachment := app.Attachment{
// FilePath: attachmentName,
// FileName: attachmentName,
// Content: imageBytes,
// MimeType: "image/png",
// }
// m.attachments = append(m.attachments, attachment)
// } else {
m.textarea.InsertString(text)
// }
return m, nil
}
@@ -260,48 +294,6 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
return m, nil
}
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
return m, nil
}
func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
return m, nil
}
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce
}
@@ -324,18 +316,31 @@ func createTextArea(existing *textarea.Model) textarea.Model {
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Attachment = styles.NewStyle().
Foreground(t.Secondary()).
Background(bgColor).
Lipgloss()
ta.Styles.SelectedAttachment = styles.NewStyle().
Foreground(t.Text()).
Background(t.Secondary()).
Lipgloss()
ta.Styles.Cursor.Color = t.Primary()
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
ta.SetWidth(layout.Current.Container.Width - 6)
if existing != nil {
ta.SetValue(existing.Value())
@@ -343,7 +348,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
ta.SetHeight(existing.Height())
}
// ta.Focus()
return ta
}
@@ -368,9 +372,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
return &editorComponent{
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
spinner: s,
interruptKeyInDebounce: false,
}
+147 -163
View File
@@ -3,65 +3,46 @@ package chat
import (
"encoding/json"
"fmt"
"path/filepath"
"slices"
"strings"
"time"
"unicode"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/layout"
"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"
)
func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
r := styles.GetMarkdownRenderer(width-7, backgroundColor)
content = strings.ReplaceAll(content, app.RootPath+"/", "")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
if len(lines) > 0 {
firstLine := lines[0]
cleaned := ansi.Strip(firstLine)
nospace := strings.ReplaceAll(cleaned, " ", "")
if nospace == "" {
lines = lines[1:]
}
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
cleaned = ansi.Strip(lastLine)
nospace = strings.ReplaceAll(cleaned, " ", "")
if nospace == "" {
lines = lines[:len(lines)-1]
}
}
}
content = strings.Join(lines, "\n")
return strings.TrimSuffix(content, "\n")
}
type blockRenderer struct {
border bool
borderColor *compat.AdaptiveColor
paddingTop int
paddingBottom int
paddingLeft int
paddingRight int
marginTop int
marginBottom int
textColor compat.AdaptiveColor
border bool
borderColor *compat.AdaptiveColor
borderColorRight bool
paddingTop int
paddingBottom int
paddingLeft int
paddingRight int
marginTop int
marginBottom int
}
type renderingOption func(*blockRenderer)
func WithTextColor(color compat.AdaptiveColor) renderingOption {
return func(c *blockRenderer) {
c.textColor = color
}
}
func WithNoBorder() renderingOption {
return func(c *blockRenderer) {
c.border = false
@@ -74,6 +55,13 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption {
}
}
func WithBorderColorRight(color compat.AdaptiveColor) renderingOption {
return func(c *blockRenderer) {
c.borderColorRight = true
c.borderColor = &color
}
}
func WithMarginTop(padding int) renderingOption {
return func(c *blockRenderer) {
c.marginTop = padding
@@ -120,13 +108,15 @@ func WithPaddingBottom(padding int) renderingOption {
}
func renderContentBlock(
app *app.App,
content string,
highlight bool,
width int,
align lipgloss.Position,
options ...renderingOption,
) string {
t := theme.CurrentTheme()
renderer := &blockRenderer{
textColor: t.TextMuted(),
border: true,
paddingTop: 1,
paddingBottom: 1,
@@ -143,7 +133,7 @@ func renderContentBlock(
}
style := styles.NewStyle().
Foreground(t.TextMuted()).
Foreground(renderer.textColor).
Background(t.BackgroundPanel()).
Width(width).
PaddingTop(renderer.paddingTop).
@@ -161,21 +151,30 @@ func renderContentBlock(
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundPanel()).
BorderRightBackground(t.Background())
if renderer.borderColorRight {
style = style.
BorderLeftBackground(t.Background()).
BorderLeftForeground(t.BackgroundPanel()).
BorderRightForeground(borderColor).
BorderRightBackground(t.Background())
}
if highlight {
style = style.
BorderLeftForeground(borderColor).
BorderRightForeground(borderColor)
}
}
if highlight {
style = style.
Foreground(t.Text()).
Background(t.BackgroundElement()).
Bold(true)
}
content = style.Render(content)
content = lipgloss.PlaceHorizontal(
width,
lipgloss.Left,
content,
styles.WhitespaceStyle(t.Background()),
)
content = lipgloss.PlaceHorizontal(
layout.Current.Viewport.Width,
align,
content,
styles.WhitespaceStyle(t.Background()),
)
if renderer.marginTop > 0 {
for range renderer.marginTop {
content = "\n" + content
@@ -186,37 +185,71 @@ func renderContentBlock(
content = content + "\n"
}
}
if highlight {
copy := app.Key(commands.MessagesCopyCommand)
// revert := app.Key(commands.MessagesRevertCommand)
background := t.Background()
header := layout.Render(
layout.FlexOptions{
Background: &background,
Direction: layout.Row,
Justify: layout.JustifyCenter,
Align: layout.AlignStretch,
Width: width - 2,
Gap: 5,
},
layout.FlexItem{
View: copy,
},
// layout.FlexItem{
// View: revert,
// },
)
header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header)
content = "\n\n\n" + header + "\n\n" + content + "\n\n\n"
}
return content
}
func renderText(
app *app.App,
message opencode.Message,
text string,
author string,
showToolDetails bool,
highlight bool,
width int,
align lipgloss.Position,
extra string,
toolCalls ...opencode.ToolInvocationPart,
) string {
t := theme.CurrentTheme()
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).
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:]
}
info := fmt.Sprintf("%s (%s)", author, timestamp)
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
messageStyle := styles.NewStyle().
Background(t.BackgroundPanel()).
Foreground(t.Text())
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 = toMarkdown(text, width, t.BackgroundPanel())
content = util.ToMarkdown(text, width, backgroundColor)
}
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
@@ -237,21 +270,28 @@ 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:
return renderContentBlock(
app,
content,
highlight,
width,
align,
WithBorderColor(t.Secondary()),
WithTextColor(t.Text()),
WithBorderColorRight(t.Secondary()),
)
case opencode.MessageRoleAssistant:
return renderContentBlock(
app,
content,
highlight,
width,
align,
WithBorderColor(t.Accent()),
)
}
@@ -259,10 +299,11 @@ func renderText(
}
func renderToolDetails(
app *app.App,
toolCall opencode.ToolInvocationPart,
messageMetadata opencode.MessageMetadata,
highlight bool,
width int,
align lipgloss.Position,
) string {
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
@@ -282,7 +323,7 @@ func renderToolDetails(
if toolCall.ToolInvocation.State == "partial-call" {
title := renderToolTitle(toolCall, messageMetadata, width)
return renderContentBlock(title, width, align)
return renderContentBlock(app, title, highlight, width)
}
toolArgsMap := make(map[string]any)
@@ -301,6 +342,12 @@ 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":
@@ -308,7 +355,7 @@ func renderToolDetails(
if preview != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = renderFile(filename, body, width, WithTruncate(6))
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
}
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
@@ -321,38 +368,39 @@ func renderToolDetails(
patch,
diff.WithWidth(width-2),
)
formattedDiff = strings.TrimSpace(formattedDiff)
formattedDiff = styles.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundPanel()).
BorderLeft(true).
BorderRight(true).
Render(formattedDiff)
body = strings.TrimSpace(formattedDiff)
body = renderContentBlock(
body,
width,
align,
WithNoBorder(),
WithPadding(0),
)
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 != "" {
body += "\n" + renderContentBlock(diagnostics, width, align)
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
title := renderToolTitle(toolCall, messageMetadata, width)
title = renderContentBlock(title, width, align)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(
app,
content,
highlight,
width,
WithPadding(0),
WithBorderColor(borderColor),
)
return content
}
}
case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content, width)
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics
}
@@ -363,14 +411,14 @@ func renderToolDetails(
if stdout != nil {
command := toolArgsMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, width, t.BackgroundPanel())
body = util.ToMarkdown(body, width, backgroundColor)
}
case "webfetch":
if format, ok := toolArgsMap["format"].(string); ok && result != nil {
body = *result
body = truncateHeight(body, 10)
body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = toMarkdown(body, width, t.BackgroundPanel())
body = util.ToMarkdown(body, width, backgroundColor)
}
}
case "todowrite":
@@ -389,7 +437,7 @@ func renderToolDetails(
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = toMarkdown(body, width, t.BackgroundPanel())
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata.JSON.ExtraFields["summary"]
@@ -424,7 +472,7 @@ func renderToolDetails(
result = &empty
}
body = *result
body = truncateHeight(body, 10)
body = util.TruncateHeight(body, 10)
}
error := ""
@@ -437,18 +485,18 @@ func renderToolDetails(
if error != "" {
body = styles.NewStyle().
Foreground(t.Error()).
Background(t.BackgroundPanel()).
Background(backgroundColor).
Render(error)
}
if body == "" && error == "" && result != nil {
body = *result
body = truncateHeight(body, 10)
body = util.TruncateHeight(body, 10)
}
title := renderToolTitle(toolCall, messageMetadata, width)
content := title + "\n\n" + body
return renderContentBlock(content, width, align)
return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor))
}
func renderToolName(name string) string {
@@ -459,8 +507,8 @@ 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)
}
@@ -505,7 +553,7 @@ func renderToolTitle(
title = fmt.Sprintf("%s %s", title, toolArgs)
case "edit", "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("%s %s", title, relative(filename))
title = fmt.Sprintf("%s %s", title, util.Relative(filename))
}
case "bash", "task":
if description, ok := toolArgsMap["description"].(string); ok {
@@ -551,50 +599,6 @@ func renderToolAction(name string) string {
return "Working..."
}
type fileRenderer struct {
filename string
content string
height int
}
type fileRenderingOption func(*fileRenderer)
func WithTruncate(height int) fileRenderingOption {
return func(c *fileRenderer) {
c.height = height
}
}
func renderFile(
filename string,
content string,
width int,
options ...fileRenderingOption) string {
t := theme.CurrentTheme()
renderer := &fileRenderer{
filename: filename,
content: content,
}
for _, option := range options {
option(renderer)
}
lines := []string{}
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimRightFunc(line, unicode.IsSpace)
line = strings.ReplaceAll(line, "\t", " ")
lines = append(lines, line)
}
content = strings.Join(lines, "\n")
if renderer.height > 0 {
content = truncateHeight(content, renderer.height)
}
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
content = toMarkdown(content, width, t.BackgroundPanel())
return content
}
func renderArgs(args *map[string]any, titleKey string) string {
if args == nil || len(*args) == 0 {
return ""
@@ -614,7 +618,7 @@ func renderArgs(args *map[string]any, titleKey string) string {
continue
}
if key == "filePath" || key == "path" {
value = relative(value.(string))
value = util.Relative(value.(string))
}
if key == titleKey {
title = fmt.Sprintf("%s", value)
@@ -628,29 +632,6 @@ func renderArgs(args *map[string]any, titleKey string) string {
return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
}
func truncateHeight(content string, height int) string {
lines := strings.Split(content, "\n")
if len(lines) > height {
return strings.Join(lines[:height], "\n")
}
return content
}
func relative(path string) string {
path = strings.TrimPrefix(path, app.CwdPath+"/")
return strings.TrimPrefix(path, app.RootPath+"/")
}
func extension(path string) string {
ext := filepath.Ext(path)
if ext == "" {
ext = ""
} else {
ext = strings.ToLower(ext[1:])
}
return ext
}
// Diagnostic represents an LSP diagnostic
type Diagnostic struct {
Range struct {
@@ -688,7 +669,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 ""
+199 -101
View File
@@ -2,9 +2,7 @@ package chat
import (
"strings"
"time"
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
@@ -19,73 +17,93 @@ import (
type MessagesComponent interface {
tea.Model
tea.ViewModel
SetSize(width, height int) tea.Cmd
View(width, height int) string
SetWidth(width int) tea.Cmd
PageUp() (tea.Model, tea.Cmd)
PageDown() (tea.Model, tea.Cmd)
HalfPageUp() (tea.Model, tea.Cmd)
HalfPageDown() (tea.Model, tea.Cmd)
First() (tea.Model, tea.Cmd)
Last() (tea.Model, tea.Cmd)
// Previous() (tea.Model, tea.Cmd)
// Next() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd)
ToolDetailsVisible() bool
Selected() string
}
type messagesComponent struct {
width, height int
width int
app *app.App
viewport viewport.Model
spinner spinner.Model
attachments viewport.Model
cache *MessageCache
rendering bool
showToolDetails bool
tail bool
partCount int
lineCount int
selectedPart int
selectedText string
}
type renderFinishedMsg struct{}
type selectedMessagePartChangedMsg struct {
part int
}
type ToggleToolDetailsMsg struct{}
func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
return tea.Batch(m.viewport.Init())
}
func (m *messagesComponent) Selected() string {
return m.selectedText
}
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg.(type) {
switch msg := msg.(type) {
case app.SendMsg:
m.viewport.GotoBottom()
m.tail = true
m.selectedPart = -1
return m, nil
case app.OptimisticMessageAddedMsg:
m.renderView()
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
return m, m.Reload()
case ToggleToolDetailsMsg:
m.showToolDetails = !m.showToolDetails
m.rendering = true
return m, m.Reload()
case app.SessionSelectedMsg:
case app.SessionLoadedMsg, app.SessionClearedMsg:
m.cache.Clear()
m.tail = true
m.rendering = true
return m, m.Reload()
case app.SessionClearedMsg:
m.cache.Clear()
cmd := m.Reload()
return m, cmd
case renderFinishedMsg:
m.rendering = false
if m.tail {
m.viewport.GotoBottom()
}
case opencode.EventListResponseEventSessionUpdated, opencode.EventListResponseEventMessageUpdated:
m.renderView()
if m.tail {
m.viewport.GotoBottom()
case selectedMessagePartChangedMsg:
return m, m.Reload()
case opencode.EventListResponseEventSessionUpdated:
if msg.Properties.Info.ID == m.app.Session.ID {
m.renderView(m.width)
if m.tail {
m.viewport.GotoBottom()
}
}
case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID {
m.renderView(m.width)
if m.tail {
m.viewport.GotoBottom()
}
}
}
@@ -94,26 +112,19 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd)
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *messagesComponent) renderView() {
if m.width == 0 {
return
}
func (m *messagesComponent) renderView(width int) {
measure := util.Measure("messages.renderView")
defer measure("messageCount", len(m.app.Messages))
t := theme.CurrentTheme()
blocks := make([]string, 0)
m.partCount = 0
m.lineCount = 0
align := lipgloss.Center
width := layout.Current.Container.Width
orphanedToolCalls := make([]opencode.ToolInvocationPart, 0)
for _, message := range m.app.Messages {
var content string
@@ -121,41 +132,100 @@ func (m *messagesComponent) renderView() {
switch message.Role {
case opencode.MessageRoleUser:
for _, part := range message.Parts {
userLoop:
for partIndex, part := range message.Parts {
switch part := part.AsUnion().(type) {
case opencode.TextPart:
key := m.cache.GenerateKey(message.ID, part.Text, layout.Current.Viewport.Width)
remainingParts := message.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.MediaType {
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(message.ID, part.Text, width, m.selectedPart == m.partCount, files)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
m.app,
message,
part.Text,
m.app.Info.User,
m.showToolDetails,
m.partCount == m.selectedPart,
width,
align,
files,
)
m.cache.Set(key, content)
}
if content != "" {
m = m.updateSelected(content, part.Text)
blocks = append(blocks, content)
}
// Only render the first text part
break userLoop
}
}
case opencode.MessageRoleAssistant:
for i, p := range message.Parts {
hasTextPart := false
for partIndex, p := range message.Parts {
switch part := p.AsUnion().(type) {
case opencode.TextPart:
hasTextPart = true
finished := message.Metadata.Time.Completed > 0
remainingParts := message.Parts[i+1:]
remainingParts := message.Parts[partIndex+1:]
toolCallParts := make([]opencode.ToolInvocationPart, 0)
// sometimes tool calls happen without an assistant message
// these should be included in this assistant message as well
if len(orphanedToolCalls) > 0 {
toolCallParts = append(toolCallParts, orphanedToolCalls...)
orphanedToolCalls = make([]opencode.ToolInvocationPart, 0)
}
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
remaining = false
case opencode.ToolInvocationPart:
toolCallParts = append(toolCallParts, part)
if part.ToolInvocation.State != "result" {
@@ -167,36 +237,44 @@ func (m *messagesComponent) renderView() {
}
if finished {
key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width, m.showToolDetails)
key := m.cache.GenerateKey(message.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,
m.showToolDetails,
m.partCount == m.selectedPart,
width,
align,
"",
toolCallParts...,
)
m.cache.Set(key, content)
}
} else {
content = renderText(
m.app,
message,
p.Text,
message.Metadata.Assistant.ModelID,
m.showToolDetails,
m.partCount == m.selectedPart,
width,
align,
"",
toolCallParts...,
)
}
if content != "" {
m = m.updateSelected(content, p.Text)
blocks = append(blocks, content)
}
case opencode.ToolInvocationPart:
if !m.showToolDetails {
if !hasTextPart {
orphanedToolCalls = append(orphanedToolCalls, part)
}
continue
}
@@ -204,33 +282,36 @@ func (m *messagesComponent) renderView() {
key := m.cache.GenerateKey(message.ID,
part.ToolInvocation.ToolCallID,
m.showToolDetails,
layout.Current.Viewport.Width,
width,
m.partCount == m.selectedPart,
)
content, cached = m.cache.Get(key)
if !cached {
content = renderToolDetails(
m.app,
part,
message.Metadata,
m.partCount == m.selectedPart,
width,
align,
)
m.cache.Set(key, content)
}
} else {
// if the tool call isn't finished, don't cache
content = renderToolDetails(
m.app,
part,
message.Metadata,
m.partCount == m.selectedPart,
width,
align,
)
}
if content != "" {
m = m.updateSelected(content, "")
blocks = append(blocks, content)
}
}
}
}
error := ""
@@ -246,42 +327,49 @@ func (m *messagesComponent) renderView() {
if error != "" {
error = renderContentBlock(
m.app,
error,
false,
width,
align,
WithBorderColor(t.Error()),
)
blocks = append(blocks, error)
m.lineCount += lipgloss.Height(error) + 1
}
}
centered := []string{}
for _, block := range blocks {
centered = append(centered, lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
block+"\n",
styles.WhitespaceStyle(t.Background()),
))
m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n"))
if m.selectedPart == m.partCount {
m.viewport.GotoBottom()
}
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1)
m.viewport.SetContent("\n" + strings.Join(centered, "\n"))
}
func (m *messagesComponent) header() string {
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 {
if m.app.Session.ID == "" {
return ""
}
t := theme.CurrentTheme()
width := layout.Current.Container.Width
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, 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"))
}
@@ -302,31 +390,29 @@ func (m *messagesComponent) header() string {
return "\n" + header + "\n"
}
func (m *messagesComponent) View() string {
func (m *messagesComponent) View(width, height int) string {
t := theme.CurrentTheme()
if m.rendering {
return lipgloss.Place(
m.width,
m.height,
width,
height,
lipgloss.Center,
lipgloss.Center,
"Loading session...",
styles.NewStyle().Background(t.Background()).Render(""),
styles.WhitespaceStyle(t.Background()),
)
}
header := lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.header(),
styles.WhitespaceStyle(t.Background()),
)
header := m.header(width)
m.viewport.SetWidth(width)
m.viewport.SetHeight(height - lipgloss.Height(header))
return styles.NewStyle().
Background(t.Background()).
Render(header + "\n" + m.viewport.View())
}
func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
if m.width == width && m.height == height {
func (m *messagesComponent) SetWidth(width int) tea.Cmd {
if m.width == width {
return nil
}
// Clear cache on resize since width affects rendering
@@ -334,23 +420,14 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
m.cache.Clear()
}
m.width = width
m.height = height
m.viewport.SetWidth(width)
m.viewport.SetHeight(height - lipgloss.Height(m.header()))
m.attachments.SetWidth(width + 40)
m.attachments.SetHeight(3)
m.renderView()
m.renderView(width)
return nil
}
func (m *messagesComponent) GetSize() (int, int) {
return m.width, m.height
}
func (m *messagesComponent) Reload() tea.Cmd {
m.rendering = true
return func() tea.Msg {
m.renderView()
m.renderView(m.width)
return renderFinishedMsg{}
}
}
@@ -375,16 +452,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
return m, nil
}
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
m.viewport.GotoTop()
func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) {
m.tail = false
return m, nil
if m.selectedPart < 0 {
m.selectedPart = m.partCount
}
m.selectedPart--
if m.selectedPart < 0 {
m.selectedPart = 0
}
return m, util.CmdHandler(selectedMessagePartChangedMsg{
part: m.selectedPart,
})
}
func (m *messagesComponent) Next() (tea.Model, tea.Cmd) {
m.tail = false
m.selectedPart++
if m.selectedPart >= m.partCount {
m.selectedPart = m.partCount
}
return m, util.CmdHandler(selectedMessagePartChangedMsg{
part: m.selectedPart,
})
}
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
m.selectedPart = 0
m.tail = false
return m, util.CmdHandler(selectedMessagePartChangedMsg{
part: m.selectedPart,
})
}
func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
m.viewport.GotoBottom()
m.selectedPart = m.partCount - 1
m.tail = true
return m, nil
return m, util.CmdHandler(selectedMessagePartChangedMsg{
part: m.selectedPart,
})
}
func (m *messagesComponent) ToolDetailsVisible() bool {
@@ -392,23 +498,15 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
}
func NewMessagesComponent(app *app.App) MessagesComponent {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
}
s := spinner.New(spinner.WithSpinner(customSpinner))
vp := viewport.New()
attachments := viewport.New()
vp.KeyMap = viewport.KeyMap{}
return &messagesComponent{
app: app,
viewport: vp,
spinner: s,
attachments: attachments,
showToolDetails: true,
cache: NewMessageCache(),
tail: true,
selectedPart: -1,
}
}
@@ -34,10 +34,6 @@ func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
return nil
}
func (c *commandsComponent) GetSize() (int, int) {
return c.width, c.height
}
func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
c.background = &color
}
@@ -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"
@@ -41,7 +40,6 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
title := itemStyle.Render(
ci.DisplayValue(),
)
return title
}
@@ -59,7 +57,6 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
type CompletionProvider interface {
GetId() string
GetEntry() CompletionItemI
GetChildEntries(query string) ([]CompletionItemI, error)
GetEmptyMessage() string
}
@@ -67,7 +64,7 @@ type CompletionProvider interface {
type CompletionSelectedMsg struct {
SearchString string
CompletionValue string
IsCommand bool
ProviderID string
}
type CompletionDialogCompleteItemMsg struct {
@@ -81,7 +78,6 @@ type CompletionDialog interface {
tea.ViewModel
SetWidth(width int)
IsEmpty() bool
SetProvider(provider CompletionProvider)
}
type completionDialogComponent struct {
@@ -116,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) {
@@ -127,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
@@ -175,9 +166,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
return c, tea.Batch(cmds...)
}
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
return c, tea.Batch(cmds...)
@@ -192,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
}
}
@@ -219,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(),
)
@@ -0,0 +1,233 @@
package dialog
import (
"log/slog"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type FindSelectedMsg struct {
FilePath string
}
type FindDialogCloseMsg struct{}
type FindDialog interface {
layout.Modal
tea.Model
tea.ViewModel
SetWidth(width int)
SetHeight(height int)
IsEmpty() bool
}
type findDialogComponent struct {
query string
completionProvider CompletionProvider
width, height int
modal *modal.Modal
textInput textinput.Model
list list.List[CompletionItemI]
}
type findDialogKeyMap struct {
Select key.Binding
Cancel key.Binding
}
var findDialogKeys = findDialogKeyMap{
Select: key.NewBinding(
key.WithKeys("enter"),
),
Cancel: key.NewBinding(
key.WithKeys("esc"),
),
}
func (f *findDialogComponent) Init() tea.Cmd {
return textinput.Blink
}
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case []CompletionItemI:
f.list.SetItems(msg)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
if f.textInput.Value() == "" {
return f, nil
}
f.textInput.SetValue("")
return f.update(msg)
}
switch {
case key.Matches(msg, findDialogKeys.Select):
item, i := f.list.GetSelectedItem()
if i == -1 {
return f, nil
}
return f, f.selectFile(item)
case key.Matches(msg, findDialogKeys.Cancel):
return f, f.Close()
default:
f.textInput, cmd = f.textInput.Update(msg)
cmds = append(cmds, cmd)
f, cmd = f.update(msg)
cmds = append(cmds, cmd)
}
}
return f, tea.Batch(cmds...)
}
func (f *findDialogComponent) update(msg tea.Msg) (*findDialogComponent, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
query := f.textInput.Value()
if query != f.query {
f.query = query
cmd = func() tea.Msg {
items, err := f.completionProvider.GetChildEntries(query)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
return items
}
cmds = append(cmds, cmd)
}
u, cmd := f.list.Update(msg)
f.list = u.(list.List[CompletionItemI])
cmds = append(cmds, cmd)
return f, tea.Batch(cmds...)
}
func (f *findDialogComponent) View() string {
t := theme.CurrentTheme()
f.textInput.SetWidth(f.width - 8)
f.list.SetMaxWidth(f.width - 4)
inputView := f.textInput.View()
inputView = styles.NewStyle().
Background(t.BackgroundElement()).
Height(1).
Width(f.width-4).
Padding(0, 0).
Render(inputView)
listView := f.list.View()
return styles.NewStyle().Height(12).Render(inputView + "\n" + listView)
}
func (f *findDialogComponent) SetWidth(width int) {
f.width = width
if width > 4 {
f.textInput.SetWidth(width - 4)
f.list.SetMaxWidth(width - 4)
}
}
func (f *findDialogComponent) SetHeight(height int) {
f.height = height
}
func (f *findDialogComponent) IsEmpty() bool {
return f.list.IsEmpty()
}
func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd {
return tea.Sequence(
f.Close(),
util.CmdHandler(FindSelectedMsg{
FilePath: item.GetValue(),
}),
)
}
func (f *findDialogComponent) Render(background string) string {
return f.modal.Render(f.View(), background)
}
func (f *findDialogComponent) Close() tea.Cmd {
f.textInput.Reset()
f.textInput.Blur()
return util.CmdHandler(modal.CloseModalMsg{})
}
func createTextInput(existing *textinput.Model) textinput.Model {
t := theme.CurrentTheme()
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.Text = styles.NewStyle().Foreground(textColor).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
ti.Prompt = " "
ti.CharLimit = -1
ti.Focus()
if existing != nil {
ti.SetValue(existing.Value())
ti.SetWidth(existing.Width())
}
return ti
}
func NewFindDialog(completionProvider CompletionProvider) FindDialog {
ti := createTextInput(nil)
li := list.NewListComponent(
[]CompletionItemI{},
10, // max visible items
completionProvider.GetEmptyMessage(),
false,
)
go func() {
items, err := completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
li.SetItems(items)
}()
return &findDialogComponent{
query: "",
completionProvider: completionProvider,
textInput: ti,
list: li,
modal: modal.New(
modal.WithTitle("Find Files"),
modal.WithMaxWidth(80),
),
}
}
+182 -135
View File
@@ -3,13 +3,11 @@ package dialog
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"sort"
"time"
"github.com/charmbracelet/bubbles/v2/key"
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/components/list"
@@ -21,8 +19,9 @@ import (
)
const (
numVisibleModels = 6
maxDialogWidth = 40
numVisibleModels = 10
minDialogWidth = 40
maxDialogWidth = 80
)
// ModelDialog interface for the model selection dialog
@@ -31,33 +30,61 @@ type ModelDialog interface {
}
type modelDialog struct {
app *app.App
availableProviders []opencode.Provider
provider opencode.Provider
width int
height int
hScrollOffset int
hScrollPossible bool
modal *modal.Modal
modelList list.List[list.StringItem]
app *app.App
allModels []ModelWithProvider
width int
height int
modal *modal.Modal
modelList list.List[ModelItem]
dialogWidth int
}
type ModelWithProvider struct {
Model opencode.Model
Provider opencode.Provider
}
type ModelItem struct {
ModelName string
ProviderName string
}
func (m ModelItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
if selected {
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
return styles.NewStyle().
Background(t.Primary()).
Foreground(t.BackgroundPanel()).
Width(width).
PaddingLeft(1).
Render(displayText)
} else {
modelStyle := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundPanel())
providerStyle := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundPanel())
modelPart := modelStyle.Render(m.ModelName)
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
combinedText := modelPart + providerPart
return styles.NewStyle().
Background(t.BackgroundPanel()).
PaddingLeft(1).
Render(combinedText)
}
}
type modelKeyMap struct {
Left key.Binding
Right key.Binding
Enter key.Binding
Escape key.Binding
}
var modelKeys = modelKeyMap{
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←", "scroll left"),
),
Right: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→", "scroll right"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select model"),
@@ -69,7 +96,7 @@ var modelKeys = modelKeyMap{
}
func (m *modelDialog) Init() tea.Cmd {
m.setupModelsForProvider(m.provider.ID)
m.setupAllModels()
return nil
}
@@ -77,34 +104,20 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, modelKeys.Left):
if m.hScrollPossible {
m.switchProvider(-1)
}
return m, nil
case key.Matches(msg, modelKeys.Right):
if m.hScrollPossible {
m.switchProvider(1)
}
return m, nil
case key.Matches(msg, modelKeys.Enter):
selectedItem, _ := m.modelList.GetSelectedItem()
models := m.models()
var selectedModel opencode.Model
for _, model := range models {
if model.Name == string(selectedItem) {
selectedModel = model
break
}
_, selectedIndex := m.modelList.GetSelectedItem()
if selectedIndex >= 0 && selectedIndex < len(m.allModels) {
selectedModel := m.allModels[selectedIndex]
return m, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(
app.ModelSelectedMsg{
Provider: selectedModel.Provider,
Model: selectedModel.Model,
}),
)
}
return m, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(
app.ModelSelectedMsg{
Provider: m.provider,
Model: selectedModel,
}),
)
return m, util.CmdHandler(modal.CloseModalMsg{})
case key.Matches(msg, modelKeys.Escape):
return m, util.CmdHandler(modal.CloseModalMsg{})
}
@@ -115,74 +128,124 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Update the list component
updatedList, cmd := m.modelList.Update(msg)
m.modelList = updatedList.(list.List[list.StringItem])
m.modelList = updatedList.(list.List[ModelItem])
return m, cmd
}
func (m *modelDialog) models() []opencode.Model {
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b opencode.Model) int {
return strings.Compare(a.Name, b.Name)
})
return models
}
func (m *modelDialog) switchProvider(offset int) {
newOffset := m.hScrollOffset + offset
if newOffset < 0 {
newOffset = len(m.availableProviders) - 1
}
if newOffset >= len(m.availableProviders) {
newOffset = 0
}
m.hScrollOffset = newOffset
m.provider = m.availableProviders[m.hScrollOffset]
m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
m.setupModelsForProvider(m.provider.ID)
}
func (m *modelDialog) View() string {
listView := m.modelList.View()
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
return strings.Join([]string{listView, scrollIndicator}, "\n")
return m.modelList.View()
}
func (m *modelDialog) getScrollIndicators(maxWidth int) string {
var indicator string
if m.hScrollPossible {
indicator = "← → (switch provider) "
}
if indicator == "" {
return ""
}
func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
maxWidth := minDialogWidth
t := theme.CurrentTheme()
return styles.NewStyle().
Foreground(t.TextMuted()).
Width(maxWidth).
Align(lipgloss.Right).
Render(indicator)
}
func (m *modelDialog) setupModelsForProvider(providerId string) {
models := m.models()
modelNames := make([]string, len(models))
for i, model := range models {
modelNames[i] = model.Name
}
m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true)
m.modelList.SetMaxWidth(maxDialogWidth)
if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.ID == providerId {
for i, model := range models {
if model.ID == m.app.Model.ID {
m.modelList.SetSelectedIndex(i)
break
}
for _, item := range modelItems {
// Calculate the width needed for this item: "ModelName (ProviderName)"
// Add 4 for the parentheses, space, and some padding
itemWidth := len(item.ModelName) + len(item.ProviderName) + 4
if itemWidth > maxWidth {
maxWidth = itemWidth
}
}
if maxWidth > maxDialogWidth {
maxWidth = maxDialogWidth
}
return maxWidth
}
func (m *modelDialog) setupAllModels() {
providers, _ := m.app.ListProviders(context.Background())
m.allModels = make([]ModelWithProvider, 0)
for _, provider := range providers {
for _, model := range provider.Models {
m.allModels = append(m.allModels, ModelWithProvider{
Model: model,
Provider: provider,
})
}
}
m.sortModels()
modelItems := make([]ModelItem, len(m.allModels))
for i, modelWithProvider := range m.allModels {
modelItems[i] = ModelItem{
ModelName: modelWithProvider.Model.Name,
ProviderName: modelWithProvider.Provider.Name,
}
}
m.dialogWidth = m.calculateOptimalWidth(modelItems)
m.modelList = list.NewListComponent(modelItems, numVisibleModels, "No models available", true)
m.modelList.SetMaxWidth(m.dialogWidth)
if len(m.allModels) > 0 {
m.modelList.SetSelectedIndex(0)
}
}
func (m *modelDialog) sortModels() {
sort.Slice(m.allModels, func(i, j int) bool {
modelA := m.allModels[i]
modelB := m.allModels[j]
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
// If both have usage times, sort by most recent first
if !usageA.IsZero() && !usageB.IsZero() {
return usageA.After(usageB)
}
// If only one has usage time, it goes first
if !usageA.IsZero() && usageB.IsZero() {
return true
}
if usageA.IsZero() && !usageB.IsZero() {
return false
}
// If neither has usage time, sort by release date desc if available
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
if !dateA.IsZero() && !dateB.IsZero() {
return dateA.After(dateB)
}
}
// If only one has release date, it goes first
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
return true
}
if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
return false
}
// If neither has usage time nor release date, fall back to alphabetical sorting
return modelA.Model.Name < modelB.Model.Name
})
}
func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
return parsed
}
return time.Time{}
}
func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
for _, usage := range m.app.State.RecentlyUsedModels {
if usage.ProviderID == providerID && usage.ModelID == modelID {
return usage.LastUsed
}
}
return time.Time{}
}
func (m *modelDialog) Render(background string) string {
@@ -194,32 +257,16 @@ func (s *modelDialog) Close() tea.Cmd {
}
func NewModelDialog(app *app.App) ModelDialog {
availableProviders, _ := app.ListProviders(context.Background())
currentProvider := availableProviders[0]
hScrollOffset := 0
if app.Provider != nil {
for i, provider := range availableProviders {
if provider.ID == app.Provider.ID {
currentProvider = provider
hScrollOffset = i
break
}
}
}
dialog := &modelDialog{
app: app,
availableProviders: availableProviders,
hScrollOffset: hScrollOffset,
hScrollPossible: len(availableProviders) > 1,
provider: currentProvider,
modal: modal.New(
modal.WithTitle(fmt.Sprintf("Select %s Model", currentProvider.Name)),
modal.WithMaxWidth(maxDialogWidth+4),
),
app: app,
}
dialog.setupModelsForProvider(currentProvider.ID)
dialog.setupAllModels()
dialog.modal = modal.New(
modal.WithTitle("Select Model"),
modal.WithMaxWidth(dialog.dialogWidth+4),
)
return dialog
}
+69 -117
View File
@@ -1,6 +1,7 @@
package diff
import (
"bufio"
"bytes"
"fmt"
"image/color"
@@ -9,6 +10,7 @@ import (
"strconv"
"strings"
"sync"
"unicode/utf8"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
@@ -72,44 +74,6 @@ type linePair struct {
right *DiffLine
}
// -------------------------------------------------------------------------
// Side-by-Side Configuration
// -------------------------------------------------------------------------
// SideBySideConfig configures the rendering of side-by-side diffs
type SideBySideConfig struct {
TotalWidth int
}
// SideBySideOption modifies a SideBySideConfig
type SideBySideOption func(*SideBySideConfig)
// NewSideBySideConfig creates a SideBySideConfig with default values
func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
config := SideBySideConfig{
TotalWidth: 160, // Default width for side-by-side view
}
for _, opt := range opts {
opt(&config)
}
return config
}
// WithTotalWidth sets the total width for side-by-side view
func WithTotalWidth(width int) SideBySideOption {
return func(s *SideBySideConfig) {
if width > 0 {
s.TotalWidth = width
}
}
}
// -------------------------------------------------------------------------
// Unified Configuration
// -------------------------------------------------------------------------
// UnifiedConfig configures the rendering of unified diffs
type UnifiedConfig struct {
Width int
@@ -121,13 +85,22 @@ type UnifiedOption func(*UnifiedConfig)
// NewUnifiedConfig creates a UnifiedConfig with default values
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
config := UnifiedConfig{
Width: 80, // Default width for unified view
Width: 80,
}
for _, opt := range opts {
opt(&config)
}
return config
}
// NewSideBySideConfig creates a SideBySideConfig with default values
func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig {
config := UnifiedConfig{
Width: 160,
}
for _, opt := range opts {
opt(&config)
}
return config
}
@@ -148,101 +121,87 @@ func WithWidth(width int) UnifiedOption {
func ParseUnifiedDiff(diff string) (DiffResult, error) {
var result DiffResult
var currentHunk *Hunk
result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
lines := strings.Split(diff, "\n")
scanner := bufio.NewScanner(strings.NewReader(diff))
var oldLine, newLine int
inFileHeader := true
for _, line := range lines {
// Parse file headers
for scanner.Scan() {
line := scanner.Text()
if inFileHeader {
if strings.HasPrefix(line, "--- a/") {
result.OldFile = strings.TrimPrefix(line, "--- a/")
result.OldFile = line[6:]
continue
}
if strings.HasPrefix(line, "+++ b/") {
result.NewFile = strings.TrimPrefix(line, "+++ b/")
result.NewFile = line[6:]
inFileHeader = false
continue
}
}
// Parse hunk headers
if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
if strings.HasPrefix(line, "@@") {
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
currentHunk = &Hunk{
Header: line,
Lines: []DiffLine{},
Lines: make([]DiffLine, 0, 10), // Pre-allocate
}
oldStart, _ := strconv.Atoi(matches[1])
newStart, _ := strconv.Atoi(matches[3])
oldLine = oldStart
newLine = newStart
// Manual parsing of hunk header is faster than regex
parts := strings.Split(line, " ")
if len(parts) > 2 {
oldRange := strings.Split(parts[1][1:], ",")
newRange := strings.Split(parts[2][1:], ",")
oldLine, _ = strconv.Atoi(oldRange[0])
newLine, _ = strconv.Atoi(newRange[0])
}
continue
}
// Ignore "No newline at end of file" markers
if strings.HasPrefix(line, "\\ No newline at end of file") {
if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
continue
}
if currentHunk == nil {
continue
}
// Process the line based on its prefix
var dl DiffLine
dl.Content = line
if len(line) > 0 {
switch line[0] {
case '+':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: 0,
NewLineNo: newLine,
Kind: LineAdded,
Content: line[1:],
})
dl.Kind = LineAdded
dl.NewLineNo = newLine
dl.Content = line[1:]
newLine++
case '-':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: 0,
Kind: LineRemoved,
Content: line[1:],
})
dl.Kind = LineRemoved
dl.OldLineNo = oldLine
dl.Content = line[1:]
oldLine++
default:
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: newLine,
Kind: LineContext,
Content: line,
})
default: // context line
dl.Kind = LineContext
dl.OldLineNo = oldLine
dl.NewLineNo = newLine
oldLine++
newLine++
}
} else {
// Handle empty lines
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: newLine,
Kind: LineContext,
Content: "",
})
} else { // empty context line
dl.Kind = LineContext
dl.OldLineNo = oldLine
dl.NewLineNo = newLine
oldLine++
newLine++
}
currentHunk.Lines = append(currentHunk.Lines, dl)
}
// Add the last hunk if there is one
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
return result, nil
return result, scanner.Err()
}
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
@@ -617,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
@@ -664,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
@@ -699,7 +662,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
}
currentPos++
i++
i += size
}
return sb.String()
@@ -744,8 +707,6 @@ func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, high
content,
width,
"...",
// stylesi.NewStyleWithColors(t.TextMuted(), bgStyle.GetBackground()).Render("..."),
// stylesi.WithForeground(stylesi.NewStyle().Background(bgStyle.GetBackground()), t.TextMuted()).Render("..."),
),
)
}
@@ -912,16 +873,17 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
HighlightIntralineChanges(&hunkCopy)
var sb strings.Builder
for _, line := range hunkCopy.Lines {
sb.WriteString(renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()))
sb.WriteString("\n")
}
sb.Grow(len(hunkCopy.Lines) * config.Width)
util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
})
return sb.String()
}
// RenderSideBySideHunk formats a hunk for side-by-side display
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
// Apply options to create the configuration
config := NewSideBySideConfig(opts...)
@@ -936,10 +898,10 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
pairs := pairLines(hunkCopy.Lines)
// Calculate column width
colWidth := config.TotalWidth / 2
colWidth := config.Width / 2
leftWidth := colWidth
rightWidth := config.TotalWidth - colWidth
rightWidth := config.Width - colWidth
var sb strings.Builder
util.WriteStringsPar(&sb, pairs, func(p linePair) string {
@@ -969,32 +931,22 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption)
}
var sb strings.Builder
for _, h := range diffResult.Hunks {
unifiedDiff := RenderUnifiedHunk(filename, h, opts...)
sb.WriteString(unifiedDiff)
}
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
return RenderUnifiedHunk(filename, h, opts...)
})
return sb.String(), nil
}
// FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
// t := theme.CurrentTheme()
func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
}
var sb strings.Builder
// config := NewSideBySideConfig(opts...)
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
// sb.WriteString(
// lipgloss.NewStyle().
// Background(t.DiffHunkHeader()).
// Foreground(t.Background()).
// Width(config.TotalWidth).
// Render(h.Header) + "\n",
// )
return RenderSideBySideHunk(filename, h, opts...)
})
@@ -0,0 +1,281 @@
package fileviewer
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"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/diff"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type DiffStyle int
const (
DiffStyleSplit DiffStyle = iota
DiffStyleUnified
)
type Model struct {
app *app.App
width, height int
viewport viewport.Model
filename *string
content *string
isDiff *bool
diffStyle DiffStyle
}
type fileRenderedMsg struct {
content string
}
func New(app *app.App) Model {
vp := viewport.New()
m := Model{
app: app,
viewport: vp,
diffStyle: DiffStyleUnified,
}
if app.State.SplitDiff {
m.diffStyle = DiffStyleSplit
}
return m
}
func (m Model) Init() tea.Cmd {
return m.viewport.Init()
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case fileRenderedMsg:
m.viewport.SetContent(msg.content)
return m, util.CmdHandler(app.FileRenderedMsg{
FilePath: *m.filename,
})
case dialog.ThemeSelectedMsg:
return m, m.render()
case tea.KeyMsg:
switch msg.String() {
// TODO
}
}
vp, cmd := m.viewport.Update(msg)
m.viewport = vp
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
if !m.HasFile() {
return ""
}
header := *m.filename
header = styles.NewStyle().
Padding(1, 2).
Width(m.width).
Background(theme.CurrentTheme().BackgroundElement()).
Foreground(theme.CurrentTheme().Text()).
Render(header)
t := theme.CurrentTheme()
close := m.app.Key(commands.FileCloseCommand)
diffToggle := m.app.Key(commands.FileDiffToggleCommand)
if m.isDiff == nil || *m.isDiff == false {
diffToggle = ""
}
layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand)
background := t.Background()
footer := layout.Render(
layout.FlexOptions{
Background: &background,
Direction: layout.Row,
Justify: layout.JustifyCenter,
Align: layout.AlignStretch,
Width: m.width - 2,
Gap: 5,
},
layout.FlexItem{
View: close,
},
layout.FlexItem{
View: layoutToggle,
},
layout.FlexItem{
View: diffToggle,
},
)
footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer)
return header + "\n" + m.viewport.View() + "\n" + footer
}
func (m *Model) Clear() (Model, tea.Cmd) {
m.filename = nil
m.content = nil
m.isDiff = nil
return *m, m.render()
}
func (m *Model) ToggleDiff() (Model, tea.Cmd) {
switch m.diffStyle {
case DiffStyleSplit:
m.diffStyle = DiffStyleUnified
default:
m.diffStyle = DiffStyleSplit
}
return *m, m.render()
}
func (m *Model) DiffStyle() DiffStyle {
return m.diffStyle
}
func (m Model) HasFile() bool {
return m.filename != nil && m.content != nil
}
func (m Model) Filename() string {
if m.filename == nil {
return ""
}
return *m.filename
}
func (m *Model) SetSize(width, height int) (Model, tea.Cmd) {
if m.width != width || m.height != height {
m.width = width
m.height = height
m.viewport.SetWidth(width)
m.viewport.SetHeight(height - 4)
return *m, m.render()
}
return *m, nil
}
func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) {
m.filename = &filename
m.content = &content
m.isDiff = &isDiff
return *m, m.render()
}
func (m *Model) render() tea.Cmd {
if m.filename == nil || m.content == nil {
m.viewport.SetContent("")
return nil
}
return func() tea.Msg {
t := theme.CurrentTheme()
var rendered string
if m.isDiff != nil && *m.isDiff {
diffResult := ""
var err error
if m.diffStyle == DiffStyleSplit {
diffResult, err = diff.FormatDiff(
*m.filename,
*m.content,
diff.WithWidth(m.width),
)
} else if m.diffStyle == DiffStyleUnified {
diffResult, err = diff.FormatUnifiedDiff(
*m.filename,
*m.content,
diff.WithWidth(m.width),
)
}
if err != nil {
rendered = styles.NewStyle().
Foreground(t.Error()).
Render(fmt.Sprintf("Error rendering diff: %v", err))
} else {
rendered = strings.TrimRight(diffResult, "\n")
}
} else {
rendered = util.RenderFile(
*m.filename,
*m.content,
m.width,
)
}
rendered = styles.NewStyle().
Width(m.width).
Background(t.BackgroundPanel()).
Render(rendered)
return fileRenderedMsg{
content: rendered,
}
}
}
func (m *Model) ScrollTo(line int) {
m.viewport.SetYOffset(line)
}
func (m *Model) ScrollToBottom() {
m.viewport.GotoBottom()
}
func (m *Model) ScrollToTop() {
m.viewport.GotoTop()
}
func (m *Model) PageUp() (Model, tea.Cmd) {
m.viewport.ViewUp()
return *m, nil
}
func (m *Model) PageDown() (Model, tea.Cmd) {
m.viewport.ViewDown()
return *m, nil
}
func (m *Model) HalfPageUp() (Model, tea.Cmd) {
m.viewport.HalfViewUp()
return *m, nil
}
func (m *Model) HalfPageDown() (Model, tea.Cmd) {
m.viewport.HalfViewDown()
return *m, nil
}
func (m Model) AtTop() bool {
return m.viewport.AtTop()
}
func (m Model) AtBottom() bool {
return m.viewport.AtBottom()
}
func (m Model) ScrollPercent() float64 {
return m.viewport.ScrollPercent()
}
func (m Model) TotalLineCount() int {
return m.viewport.TotalLineCount()
}
func (m Model) VisibleLineCount() int {
return m.viewport.VisibleLineCount()
}
+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 != "" {
@@ -135,7 +135,7 @@ func (m *Modal) Render(contentView string, background string) string {
col := (bgWidth - modalWidth) / 2
return layout.PlaceOverlay(
col,
col-1, // TODO: whyyyyy
row,
modalView,
background,
+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
@@ -37,7 +37,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 +76,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().
File diff suppressed because it is too large Load Diff
+43 -4
View File
@@ -5,19 +5,58 @@ import (
"fmt"
"log/slog"
"os"
"time"
"github.com/BurntSushi/toml"
)
type ModelUsage struct {
ProviderID string `toml:"provider_id"`
ModelID string `toml:"model_id"`
LastUsed time.Time `toml:"last_used"`
}
type State struct {
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
}
func NewState() *State {
return &State{
Theme: "opencode",
Theme: "opencode",
RecentlyUsedModels: make([]ModelUsage, 0),
}
}
// UpdateModelUsage updates the recently used models list with the specified model
func (s *State) UpdateModelUsage(providerID, modelID string) {
now := time.Now()
// Check if this model is already in the list
for i, usage := range s.RecentlyUsedModels {
if usage.ProviderID == providerID && usage.ModelID == modelID {
s.RecentlyUsedModels[i].LastUsed = now
usage := s.RecentlyUsedModels[i]
copy(s.RecentlyUsedModels[1:i+1], s.RecentlyUsedModels[0:i])
s.RecentlyUsedModels[0] = usage
return
}
}
newUsage := ModelUsage{
ProviderID: providerID,
ModelID: modelID,
LastUsed: now,
}
// Prepend to slice and limit to last 50 entries
s.RecentlyUsedModels = append([]ModelUsage{newUsage}, s.RecentlyUsedModels...)
if len(s.RecentlyUsedModels) > 50 {
s.RecentlyUsedModels = s.RecentlyUsedModels[:50]
}
}
+97 -26
View File
@@ -4,7 +4,9 @@ import (
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type Direction int
@@ -34,11 +36,13 @@ const (
)
type FlexOptions struct {
Direction Direction
Justify Justify
Align Align
Width int
Height int
Background *compat.AdaptiveColor
Direction Direction
Justify Justify
Align Align
Width int
Height int
Gap int
}
type FlexItem struct {
@@ -53,6 +57,12 @@ func Render(opts FlexOptions, items ...FlexItem) string {
return ""
}
t := theme.CurrentTheme()
if opts.Background == nil {
background := t.Background()
opts.Background = &background
}
// Calculate dimensions for each item
mainAxisSize := opts.Width
crossAxisSize := opts.Height
@@ -72,8 +82,14 @@ func Render(opts FlexOptions, items ...FlexItem) string {
}
}
// Account for gaps between items
totalGapSize := 0
if len(items) > 1 && opts.Gap > 0 {
totalGapSize = opts.Gap * (len(items) - 1)
}
// Calculate available space for grow items
availableSpace := max(mainAxisSize-totalFixedSize, 0)
availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0)
// Calculate size for each grow item
growItemSize := 0
@@ -108,6 +124,7 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// For row direction, constrain width and handle height alignment
if itemSize > 0 {
view = styles.NewStyle().
Background(*opts.Background).
Width(itemSize).
Height(crossAxisSize).
Render(view)
@@ -116,31 +133,65 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// Apply cross-axis alignment
switch opts.Align {
case AlignCenter:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view)
view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Center,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignEnd:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view)
view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Bottom,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStart:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view)
view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Top,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStretch:
// Already stretched by Height setting above
}
} else {
// For column direction, constrain height and handle width alignment
if itemSize > 0 {
view = styles.NewStyle().
Height(itemSize).
Width(crossAxisSize).
Render(view)
style := styles.NewStyle().
Background(*opts.Background).
Height(itemSize)
// Only set width for stretch alignment
if opts.Align == AlignStretch {
style = style.Width(crossAxisSize)
}
view = style.Render(view)
}
// Apply cross-axis alignment
switch opts.Align {
case AlignCenter:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view)
view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Center,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignEnd:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view)
view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Right,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStart:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view)
view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Left,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStretch:
// Already stretched by Width setting above
}
@@ -154,11 +205,14 @@ func Render(opts FlexOptions, items ...FlexItem) string {
}
}
// Calculate total actual size
// Calculate total actual size including gaps
totalActualSize := 0
for _, size := range actualSizes {
totalActualSize += size
}
if len(items) > 1 && opts.Gap > 0 {
totalActualSize += opts.Gap * (len(items) - 1)
}
// Apply justification
remainingSpace := max(mainAxisSize-totalActualSize, 0)
@@ -191,12 +245,17 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// Build the final layout
var parts []string
spaceStyle := styles.NewStyle().Background(*opts.Background)
// Add space before if needed
if spaceBefore > 0 {
if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceBefore))
space := strings.Repeat(" ", spaceBefore)
parts = append(parts, spaceStyle.Render(space))
} else {
parts = append(parts, strings.Repeat("\n", spaceBefore))
// For vertical layout, add empty lines as separate parts
for range spaceBefore {
parts = append(parts, "")
}
}
}
@@ -205,11 +264,19 @@ func Render(opts FlexOptions, items ...FlexItem) string {
parts = append(parts, view)
// Add space between items (not after the last one)
if i < len(sizedViews)-1 && spaceBetween > 0 {
if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceBetween))
} else {
parts = append(parts, strings.Repeat("\n", spaceBetween))
if i < len(sizedViews)-1 {
// Add gap first, then any additional spacing from justification
totalSpacing := opts.Gap + spaceBetween
if totalSpacing > 0 {
if opts.Direction == Row {
space := strings.Repeat(" ", totalSpacing)
parts = append(parts, spaceStyle.Render(space))
} else {
// For vertical layout, add empty lines as separate parts
for range totalSpacing {
parts = append(parts, "")
}
}
}
}
}
@@ -217,9 +284,13 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// Add space after if needed
if spaceAfter > 0 {
if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceAfter))
space := strings.Repeat(" ", spaceAfter)
parts = append(parts, spaceStyle.Render(space))
} else {
parts = append(parts, strings.Repeat("\n", spaceAfter))
// For vertical layout, add empty lines as separate parts
for range spaceAfter {
parts = append(parts, "")
}
}
}
+4
View File
@@ -27,6 +27,10 @@ type LoadedTheme struct {
name string
}
func (t *LoadedTheme) Name() string {
return t.name
}
type colorRef struct {
value any
resolved bool
+4
View File
@@ -27,6 +27,10 @@ func NewSystemTheme(terminalBg color.Color, isDark bool) *SystemTheme {
return theme
}
func (t *SystemTheme) Name() string {
return "system"
}
// initializeColors sets up all theme colors
func (t *SystemTheme) initializeColors() {
// Generate gray scale based on terminal background
+2
View File
@@ -8,6 +8,8 @@ import (
// All colors must be defined as compat.AdaptiveColor to support
// both light and dark terminal backgrounds.
type Theme interface {
Name() string
// Background colors
Background() compat.AdaptiveColor // Radix 1
BackgroundPanel() compat.AdaptiveColor // Radix 2
+378 -148
View File
@@ -19,6 +19,7 @@ import (
"github.com/sst/opencode/internal/components/chat"
cmdcomp "github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/fileviewer"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
@@ -40,6 +41,7 @@ const (
)
const interruptDebounceTimeout = 1 * time.Second
const fileViewerFullWidthCutoff = 160
type appModel struct {
width, height int
@@ -49,13 +51,21 @@ type appModel struct {
editor chat.EditorComponent
messages chat.MessagesComponent
completions dialog.CompletionDialog
completionManager *completions.CompletionManager
commandProvider dialog.CompletionProvider
fileProvider dialog.CompletionProvider
showCompletionDialog bool
fileCompletionActive bool
leaderBinding *key.Binding
isLeaderSequence bool
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
lastScroll time.Time
messagesRight bool
fileViewer fileviewer.Model
lastMouse tea.Mouse
fileViewerStart int
fileViewerEnd int
fileViewerHit bool
}
func (a appModel) Init() tea.Cmd {
@@ -71,6 +81,7 @@ func (a appModel) Init() tea.Cmd {
cmds = append(cmds, a.status.Init())
cmds = append(cmds, a.completions.Init())
cmds = append(cmds, a.toastManager.Init())
cmds = append(cmds, a.fileViewer.Init())
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
@@ -96,15 +107,38 @@ var BUGGED_SCROLL_KEYS = map[string]bool{
"m": true,
"[": true,
";": true,
"<": true,
}
func isScrollRelatedInput(keyString string) bool {
if len(keyString) == 0 {
return false
}
for _, char := range keyString {
charStr := string(char)
if !BUGGED_SCROLL_KEYS[charStr] {
return false
}
}
if len(keyString) > 3 &&
(keyString[len(keyString)-1] == 'M' || keyString[len(keyString)-1] == 'm') {
return true
}
return len(keyString) > 1
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
keyString := msg.String()
if time.Since(a.lastScroll) < time.Millisecond*100 && BUGGED_SCROLL_KEYS[keyString] {
if time.Since(a.lastScroll) < time.Millisecond*100 && (BUGGED_SCROLL_KEYS[keyString] || isScrollRelatedInput(keyString)) {
return a, nil
}
@@ -112,10 +146,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.modal != nil {
switch keyString {
// Escape always closes current modal
case "esc", "ctrl+c":
case "esc":
cmd := a.modal.Close()
a.modal = nil
return a, cmd
case "ctrl+c":
// give the modal a chance to handle the ctrl+c
updatedModal, cmd := a.modal.Update(msg)
a.modal = updatedModal.(layout.Modal)
if cmd != nil {
return a, cmd
}
cmd = a.modal.Close()
a.modal = nil
return a, cmd
}
// Pass all other key presses to the modal
@@ -134,37 +178,38 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// 3. Handle completions trigger
if keyString == "/" && !a.showCompletionDialog {
if keyString == "/" &&
!a.showCompletionDialog &&
a.editor.Value() == "" {
a.showCompletionDialog = true
a.fileCompletionActive = false
initialValue := "/"
currentInput := a.editor.Value()
// if the input doesn't end with a space,
// then we want to include the last word
// (ie, `packages/`)
if !strings.HasSuffix(currentInput, " ") {
words := strings.Split(a.editor.Value(), " ")
if len(words) > 0 {
lastWord := words[len(words)-1]
lastWord = strings.TrimSpace(lastWord)
initialValue = lastWord + "/"
}
}
updated, cmd := a.completions.Update(
app.CompletionDialogTriggeredMsg{
InitialValue: initialValue,
},
)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
updated, cmd = a.editor.Update(msg)
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
updated, cmd = a.updateCompletions(msg)
// Set command provider for command completion
a.completions = dialog.NewCompletionDialogComponent(a.commandProvider)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
return a, tea.Sequence(cmds...)
}
// Handle file completions trigger
if keyString == "@" &&
!a.showCompletionDialog {
a.showCompletionDialog = true
a.fileCompletionActive = true
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
// Set file provider for file completion
a.completions = dialog.NewCompletionDialogComponent(a.fileProvider)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
@@ -173,8 +218,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showCompletionDialog {
switch keyString {
case "tab", "enter", "esc", "ctrl+c":
updated, cmd := a.updateCompletions(msg)
case "tab", "enter", "esc", "ctrl+c", "up", "down":
updated, cmd := a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
@@ -184,7 +229,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
updated, cmd = a.updateCompletions(msg)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
@@ -246,10 +291,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.modal != nil {
return a, nil
}
updated, cmd := a.messages.Update(msg)
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
var cmd tea.Cmd
if a.fileViewerHit {
a.fileViewer, cmd = a.fileViewer.Update(msg)
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.Update(msg)
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
return a, tea.Batch(cmds...)
case tea.MouseMotionMsg:
a.lastMouse = msg.Mouse()
a.fileViewerHit = a.fileViewer.HasFile() &&
a.lastMouse.X > a.fileViewerStart &&
a.lastMouse.X < a.fileViewerEnd
case tea.MouseClickMsg:
a.lastMouse = msg.Mouse()
a.fileViewerHit = a.fileViewer.HasFile() &&
a.lastMouse.X > a.fileViewerStart &&
a.lastMouse.X < a.fileViewerEnd
case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{
Background: msg.Color,
@@ -266,6 +329,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case modal.CloseModalMsg:
a.editor.Focus()
var cmd tea.Cmd
if a.modal != nil {
cmd = a.modal.Close()
@@ -286,10 +350,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, toast.NewErrorToast(msg.Error())
case app.SendMsg:
a.showCompletionDialog = false
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
a.fileCompletionActive = false
case opencode.EventListResponseEventInstallationUpdated:
return a, toast.NewSuccessToast(
"opencode updated to "+msg.Properties.Version+", restart to apply.",
@@ -349,22 +414,47 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
}
case opencode.EventListResponseEventFileWatcherUpdated:
if a.fileViewer.HasFile() {
if a.fileViewer.Filename() == msg.Properties.File {
return a.openFile(msg.Properties.File)
}
}
case tea.WindowSizeMsg:
msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
container := min(a.width, 84)
if a.fileViewer.HasFile() {
if a.width < fileViewerFullWidthCutoff {
container = a.width
} else {
container = min(min(a.width, max(a.width/2, 50)), 84)
}
}
layout.Current = &layout.LayoutInfo{
Viewport: layout.Dimensions{
Width: a.width,
Height: a.height,
},
Container: layout.Dimensions{
Width: min(a.width, 80),
Width: container,
},
}
// Update child component sizes
messagesHeight := a.height - 6 // Leave room for editor and status bar
a.messages.SetSize(a.width, messagesHeight)
a.editor.SetSize(min(a.width, 80), 5)
mainWidth := layout.Current.Container.Width
a.messages.SetWidth(mainWidth - 4)
sideWidth := a.width - mainWidth
if a.width < fileViewerFullWidthCutoff {
sideWidth = a.width
}
a.fileViewerStart = mainWidth
a.fileViewerEnd = a.fileViewerStart + sideWidth
if a.messagesRight {
a.fileViewerStart = 0
a.fileViewerEnd = sideWidth
}
a.fileViewer, cmd = a.fileViewer.SetSize(sideWidth, layout.Current.Viewport.Height)
cmds = append(cmds, cmd)
case app.SessionSelectedMsg:
messages, err := a.app.ListMessages(context.Background(), msg.ID)
if err != nil {
@@ -373,11 +463,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
a.app.Session = msg
a.app.Messages = messages
return a, util.CmdHandler(app.SessionLoadedMsg{})
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
a.app.State.Provider = msg.Provider.ID
a.app.State.Model = msg.Model.ID
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
a.app.SaveState()
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
@@ -394,24 +486,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Reset interrupt key state after timeout
a.interruptKeyState = InterruptKeyIdle
a.editor.SetInterruptKeyInDebounce(false)
case dialog.FindSelectedMsg:
return a.openFile(msg.FilePath)
}
// update status bar
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
a.status = s.(status.StatusComponent)
// update editor
u, cmd := a.editor.Update(msg)
a.editor = u.(chat.EditorComponent)
cmds = append(cmds, cmd)
// update messages
u, cmd = a.messages.Update(msg)
a.messages = u.(chat.MessagesComponent)
cmds = append(cmds, cmd)
// update modal
if a.modal != nil {
u, cmd := a.modal.Update(msg)
a.modal = u.(layout.Modal)
@@ -424,85 +514,95 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
fv, cmd := a.fileViewer.Update(msg)
a.fileViewer = fv
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
func (a appModel) View() string {
editorView := a.editor.View()
lines := a.editor.Lines()
messagesView := a.messages.View()
if a.app.Session.ID == "" {
messagesView = a.home()
}
editorHeight := max(lines, 5)
t := theme.CurrentTheme()
centeredEditorView := lipgloss.PlaceHorizontal(
a.width,
lipgloss.Center,
editorView,
styles.WhitespaceStyle(t.Background()),
)
mainLayout := layout.Render(
layout.FlexOptions{
Direction: layout.Column,
Width: a.width,
Height: a.height,
},
layout.FlexItem{
View: messagesView,
Grow: true,
},
layout.FlexItem{
View: centeredEditorView,
FixedSize: 5,
},
// layout.FlexItem{
// View: a.status.View(),
// FixedSize: 1,
// },
)
var mainLayout string
mainWidth := layout.Current.Container.Width - 4
if a.app.Session.ID == "" {
mainLayout = a.home(mainWidth)
} else {
mainLayout = a.chat(mainWidth)
}
mainLayout = styles.NewStyle().
Background(t.Background()).
Padding(0, 2).
Render(mainLayout)
if lines > 1 {
editorWidth := min(a.width, 80)
editorX := (a.width - editorWidth) / 2
editorY := a.height - editorHeight
mainLayout = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(),
mainHeight := lipgloss.Height(mainLayout)
if a.fileViewer.HasFile() {
file := a.fileViewer.View()
baseStyle := styles.NewStyle().Background(t.BackgroundPanel())
sidePanel := baseStyle.Height(mainHeight).Render(file)
if a.width >= fileViewerFullWidthCutoff {
if a.messagesRight {
mainLayout = lipgloss.JoinHorizontal(
lipgloss.Top,
sidePanel,
mainLayout,
)
} else {
mainLayout = lipgloss.JoinHorizontal(
lipgloss.Top,
mainLayout,
sidePanel,
)
}
} else {
mainLayout = sidePanel
}
} else {
mainLayout = lipgloss.PlaceHorizontal(
a.width,
lipgloss.Center,
mainLayout,
styles.WhitespaceStyle(t.Background()),
)
}
if a.showCompletionDialog {
editorWidth := min(a.width, 80)
editorX := (a.width - editorWidth) / 2
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
overlayHeight := lipgloss.Height(overlay)
editorY := a.height - editorHeight + 1
mainLayout = layout.PlaceOverlay(
editorX,
editorY-overlayHeight,
overlay,
mainLayout,
)
}
mainStyle := styles.NewStyle().Background(t.Background())
mainLayout = mainStyle.Render(mainLayout)
if a.modal != nil {
mainLayout = a.modal.Render(mainLayout)
}
mainLayout = a.toastManager.RenderOverlay(mainLayout)
if theme.CurrentThemeUsesAnsiColors() {
mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
}
return mainLayout + "\n" + a.status.View()
}
func (a appModel) home() string {
func (a appModel) openFile(filepath string) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
response, err := a.app.Client.File.Read(
context.Background(),
opencode.FileReadParams{
Path: opencode.F(filepath),
},
)
if err != nil {
slog.Error("Failed to read file", "error", err)
return a, toast.NewErrorToast("Failed to read file")
}
a.fileViewer, cmd = a.fileViewer.SetFile(
filepath,
response.Content,
response.Type == "patch",
)
return a, cmd
}
func (a appModel) home(width int) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Background(t.Background())
base := baseStyle.Render
@@ -534,7 +634,7 @@ func (a appModel) home() string {
logoAndVersion := strings.Join([]string{logo, version}, "\n")
logoAndVersion = lipgloss.PlaceHorizontal(
a.width,
width,
lipgloss.Center,
logoAndVersion,
styles.WhitespaceStyle(t.Background()),
@@ -545,13 +645,15 @@ func (a appModel) home() string {
cmdcomp.WithLimit(6),
)
cmds := lipgloss.PlaceHorizontal(
a.width,
width,
lipgloss.Center,
commandsView.View(),
styles.WhitespaceStyle(t.Background()),
)
lines := []string{}
lines = append(lines, "")
lines = append(lines, "")
lines = append(lines, logoAndVersion)
lines = append(lines, "")
lines = append(lines, "")
@@ -559,18 +661,100 @@ func (a appModel) home() string {
// lines = append(lines, base("config ")+muted(config))
// lines = append(lines, "")
lines = append(lines, cmds)
lines = append(lines, "")
lines = append(lines, "")
return lipgloss.Place(
a.width,
a.height-5,
mainHeight := lipgloss.Height(strings.Join(lines, "\n"))
editorWidth := min(width, 80)
editorView := a.editor.View(editorWidth)
editorView = lipgloss.PlaceHorizontal(
width,
lipgloss.Center,
editorView,
styles.WhitespaceStyle(t.Background()),
)
lines = append(lines, editorView)
editorLines := a.editor.Lines()
mainLayout := lipgloss.Place(
width,
a.height,
lipgloss.Center,
lipgloss.Center,
baseStyle.Render(strings.Join(lines, "\n")),
styles.WhitespaceStyle(t.Background()),
)
editorX := (width - editorWidth) / 2
editorY := (a.height / 2) + (mainHeight / 2) - 2
if editorLines > 1 {
mainLayout = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(editorWidth),
mainLayout,
)
}
if a.showCompletionDialog {
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
overlayHeight := lipgloss.Height(overlay)
mainLayout = layout.PlaceOverlay(
editorX,
editorY-overlayHeight+1,
overlay,
mainLayout,
)
}
return mainLayout
}
func (a appModel) chat(width int) string {
editorView := a.editor.View(width)
lines := a.editor.Lines()
messagesView := a.messages.View(width, a.height-5)
editorWidth := lipgloss.Width(editorView)
editorHeight := max(lines, 5)
mainLayout := messagesView + "\n" + editorView
editorX := (a.width - editorWidth) / 2
if lines > 1 {
editorY := a.height - editorHeight
mainLayout = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(width),
mainLayout,
)
}
if a.showCompletionDialog {
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
overlayHeight := lipgloss.Height(overlay)
editorY := a.height - editorHeight + 1
mainLayout = layout.PlaceOverlay(
editorX,
editorY-overlayHeight,
overlay,
mainLayout,
)
}
return mainLayout
}
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
cmds := []tea.Cmd{
util.CmdHandler(commands.CommandExecutedMsg(command)),
}
@@ -619,11 +803,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return nil
}
os.Remove(tmpfile.Name())
// attachments := m.attachments
// m.attachments = nil
return app.SendMsg{
Text: string(content),
Attachments: []app.Attachment{}, // attachments,
Text: string(content),
}
})
cmds = append(cmds, cmd)
@@ -649,6 +830,17 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
shareUrl := response.Share.URL
cmds = append(cmds, tea.SetClipboard(shareUrl))
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
case commands.SessionUnshareCommand:
if a.app.Session.ID == "" {
return a, nil
}
_, err := a.app.Client.Session.Unshare(context.Background(), a.app.Session.ID)
if err != nil {
slog.Error("Failed to unshare session", "error", err)
return a, toast.NewErrorToast("Failed to unshare session")
}
a.app.Session.Share.URL = ""
cmds = append(cmds, toast.NewSuccessToast("Session unshared successfully"))
case commands.SessionInterruptCommand:
if a.app.Session.ID == "" {
return a, nil
@@ -674,6 +866,22 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
case commands.ThemeListCommand:
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
case commands.FileListCommand:
a.editor.Blur()
provider := completions.NewFileAndFolderContextGroup(a.app)
findDialog := dialog.NewFindDialog(provider)
findDialog.SetWidth(layout.Current.Container.Width - 8)
a.modal = findDialog
case commands.FileCloseCommand:
a.fileViewer, cmd = a.fileViewer.Clear()
cmds = append(cmds, cmd)
case commands.FileDiffToggleCommand:
a.fileViewer, cmd = a.fileViewer.ToggleDiff()
a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
a.app.SaveState()
cmds = append(cmds, cmd)
case commands.FileSearchCommand:
return a, nil
case commands.ProjectInitCommand:
cmds = append(cmds, a.app.InitializeProject(context.Background()))
case commands.InputClearCommand:
@@ -695,20 +903,6 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
updated, cmd := a.editor.Newline()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.HistoryPreviousCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.editor.Previous()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.HistoryNextCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.editor.Next()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.MessagesFirstCommand:
updated, cmd := a.messages.First()
a.messages = updated.(chat.MessagesComponent)
@@ -718,43 +912,75 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageUpCommand:
updated, cmd := a.messages.PageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
if a.fileViewer.HasFile() {
a.fileViewer, cmd = a.fileViewer.PageUp()
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.PageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
case commands.MessagesPageDownCommand:
updated, cmd := a.messages.PageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
if a.fileViewer.HasFile() {
a.fileViewer, cmd = a.fileViewer.PageDown()
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.PageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
case commands.MessagesHalfPageUpCommand:
updated, cmd := a.messages.HalfPageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
if a.fileViewer.HasFile() {
a.fileViewer, cmd = a.fileViewer.HalfPageUp()
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.HalfPageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
case commands.MessagesHalfPageDownCommand:
updated, cmd := a.messages.HalfPageDown()
if a.fileViewer.HasFile() {
a.fileViewer, cmd = a.fileViewer.HalfPageDown()
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.HalfPageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
case commands.MessagesPreviousCommand:
updated, cmd := a.messages.Previous()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesNextCommand:
updated, cmd := a.messages.Next()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesLayoutToggleCommand:
a.messagesRight = !a.messagesRight
a.app.State.MessagesRight = a.messagesRight
a.app.SaveState()
case commands.MessagesCopyCommand:
selected := a.messages.Selected()
if selected != "" {
cmd = tea.SetClipboard(selected)
cmds = append(cmds, cmd)
cmd = toast.NewSuccessToast("Message copied to clipboard")
cmds = append(cmds, cmd)
}
case commands.MessagesRevertCommand:
case commands.AppExitCommand:
return a, tea.Quit
}
return a, tea.Batch(cmds...)
}
func (a appModel) updateCompletions(msg tea.Msg) (tea.Model, tea.Cmd) {
currentInput := a.editor.Value()
if currentInput != "" {
provider := a.completionManager.GetProvider(currentInput)
a.completions.SetProvider(provider)
}
return a.completions.Update(msg)
}
func NewModel(app *app.App) tea.Model {
completionManager := completions.NewCompletionManager(app)
initialProvider := completionManager.DefaultProvider()
commandProvider := completions.NewCommandCompletionProvider(app)
fileProvider := completions.NewFileAndFolderContextGroup(app)
messages := chat.NewMessagesComponent(app)
editor := chat.NewEditorComponent(app)
completions := dialog.NewCompletionDialogComponent(initialProvider)
completions := dialog.NewCompletionDialogComponent(commandProvider)
var leaderBinding *key.Binding
if app.Config.Keybinds.Leader != "" {
@@ -768,12 +994,16 @@ func NewModel(app *app.App) tea.Model {
editor: editor,
messages: messages,
completions: completions,
completionManager: completionManager,
commandProvider: commandProvider,
fileProvider: fileProvider,
leaderBinding: leaderBinding,
isLeaderSequence: false,
showCompletionDialog: false,
fileCompletionActive: false,
toastManager: toast.NewToastManager(),
interruptKeyState: InterruptKeyIdle,
fileViewer: fileviewer.New(app),
messagesRight: app.State.MessagesRight,
}
return model
+22 -32
View File
@@ -2,49 +2,39 @@ package util
import (
"strings"
"sync"
)
// MapReducePar performs a parallel map-reduce operation on a slice of items.
// It applies a function to each item in the slice concurrently,
// and combines the results serially using a reducer returned from
// each one of the functions, allowing the use of closures.
func MapReducePar[a, b any](items []a, init b, fn func(a) func(b) b) b {
itemCount := len(items)
locks := make([]*sync.Mutex, itemCount)
mapped := make([]func(b) b, itemCount)
func mapParallel[in, out any](items []in, fn func(in) out) chan out {
mapChans := make([]chan out, 0, len(items))
for i, value := range items {
lock := &sync.Mutex{}
lock.Lock()
locks[i] = lock
for _, v := range items {
ch := make(chan out)
mapChans = append(mapChans, ch)
go func() {
defer lock.Unlock()
mapped[i] = fn(value)
defer close(ch)
ch <- fn(v)
}()
}
result := init
for i := range itemCount {
locks[i].Lock()
defer locks[i].Unlock()
f := mapped[i]
if f != nil {
result = f(result)
}
}
resultChan := make(chan out)
return result
go func() {
defer close(resultChan)
for _, ch := range mapChans {
v := <-ch
resultChan <- v
}
}()
return resultChan
}
// WriteStringsPar allows to iterate over a list and compute strings in parallel,
// yet write them in order.
func WriteStringsPar[a any](sb *strings.Builder, items []a, fn func(a) string) {
MapReducePar(items, sb, func(item a) func(*strings.Builder) *strings.Builder {
str := fn(item)
return func(sbdr *strings.Builder) *strings.Builder {
sbdr.WriteString(str)
return sbdr
}
})
ch := mapParallel(items, fn)
for v := range ch {
sb.WriteString(v)
}
}
@@ -0,0 +1,23 @@
package util_test
import (
"strconv"
"strings"
"testing"
"time"
"github.com/sst/opencode/internal/util"
)
func TestWriteStringsPar(t *testing.T) {
items := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
sb := strings.Builder{}
util.WriteStringsPar(&sb, items, func(i int) string {
// sleep for the inverse duration so that later items finish first
time.Sleep(time.Duration(10-i) * time.Millisecond)
return strconv.Itoa(i)
})
if sb.String() != "0123456789" {
t.Fatalf("expected 0123456789, got %s", sb.String())
}
}
+109
View File
@@ -0,0 +1,109 @@
package util
import (
"fmt"
"path/filepath"
"strings"
"unicode"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
var RootPath string
var CwdPath string
type fileRenderer struct {
filename string
content string
height int
}
type fileRenderingOption func(*fileRenderer)
func WithTruncate(height int) fileRenderingOption {
return func(c *fileRenderer) {
c.height = height
}
}
func RenderFile(
filename string,
content string,
width int,
options ...fileRenderingOption) string {
t := theme.CurrentTheme()
renderer := &fileRenderer{
filename: filename,
content: content,
}
for _, option := range options {
option(renderer)
}
lines := []string{}
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimRightFunc(line, unicode.IsSpace)
line = strings.ReplaceAll(line, "\t", " ")
lines = append(lines, line)
}
content = strings.Join(lines, "\n")
if renderer.height > 0 {
content = TruncateHeight(content, renderer.height)
}
content = fmt.Sprintf("```%s\n%s\n```", Extension(renderer.filename), content)
content = ToMarkdown(content, width, t.BackgroundPanel())
return content
}
func TruncateHeight(content string, height int) string {
lines := strings.Split(content, "\n")
if len(lines) > height {
return strings.Join(lines[:height], "\n")
}
return content
}
func Relative(path string) string {
path = strings.TrimPrefix(path, CwdPath+"/")
return strings.TrimPrefix(path, RootPath+"/")
}
func Extension(path string) string {
ext := filepath.Ext(path)
if ext == "" {
ext = ""
} else {
ext = strings.ToLower(ext[1:])
}
return ext
}
func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
r := styles.GetMarkdownRenderer(width-6, backgroundColor)
content = strings.ReplaceAll(content, RootPath+"/", "")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
if len(lines) > 0 {
firstLine := lines[0]
cleaned := ansi.Strip(firstLine)
nospace := strings.ReplaceAll(cleaned, " ", "")
if nospace == "" {
lines = lines[1:]
}
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
cleaned = ansi.Strip(lastLine)
nospace = strings.ReplaceAll(cleaned, " ", "")
if nospace == "" {
lines = lines[:len(lines)-1]
}
}
}
content = strings.Join(lines, "\n")
return strings.TrimSuffix(content, "\n")
}
@@ -0,0 +1,7 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/debian
{
"name": "Development",
"image": "mcr.microsoft.com/devcontainers/go:1.23-bookworm",
"postCreateCommand": "go mod tidy"
}
+49
View File
@@ -0,0 +1,49 @@
name: CI
on:
push:
branches-ignore:
- 'generated'
- 'codegen/**'
- 'integrated/**'
- 'stl-preview-head/**'
- 'stl-preview-base/**'
pull_request:
branches-ignore:
- 'stl-preview-head/**'
- 'stl-preview-base/**'
jobs:
lint:
timeout-minutes: 10
name: lint
runs-on: ${{ github.repository == 'stainless-sdks/opencode-go' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@v4
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Run lints
run: ./scripts/lint
test:
timeout-minutes: 10
name: test
runs-on: ${{ github.repository == 'stainless-sdks/opencode-go' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@v4
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Bootstrap
run: ./scripts/bootstrap
- name: Run tests
run: ./scripts/test
+4
View File
@@ -0,0 +1,4 @@
.prism.log
codegen.log
Brewfile.lock.json
.idea/
@@ -0,0 +1,3 @@
{
".": "0.1.0-alpha.8"
}
+4
View File
@@ -0,0 +1,4 @@
configured_endpoints: 20
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-945f9da9e9a4c4008834deef63e4346c0076e020eed3d3c98c249095033c1ac5.yml
openapi_spec_hash: 522a44f6cb0677435fe2ac7693848ad7
config_hash: 6c8822d278ba83456e5eed6d774ca230
+1
View File
@@ -0,0 +1 @@
brew "go"
+73
View File
@@ -0,0 +1,73 @@
# Changelog
## 0.1.0-alpha.8 (2025-07-02)
Full Changelog: [v0.1.0-alpha.7...v0.1.0-alpha.8](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.7...v0.1.0-alpha.8)
### Features
* **api:** update via SDK Studio ([651e937](https://github.com/sst/opencode-sdk-go/commit/651e937c334e1caba3b968e6cac865c219879519))
## 0.1.0-alpha.7 (2025-06-30)
Full Changelog: [v0.1.0-alpha.6...v0.1.0-alpha.7](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.6...v0.1.0-alpha.7)
### Features
* **api:** update via SDK Studio ([13550a5](https://github.com/sst/opencode-sdk-go/commit/13550a5c65d77325e945ed99fe0799cd1107b775))
* **api:** update via SDK Studio ([7b73730](https://github.com/sst/opencode-sdk-go/commit/7b73730c7fa62ba966dda3541c3e97b49be8d2bf))
### Chores
* **ci:** only run for pushes and fork pull requests ([bea59b8](https://github.com/sst/opencode-sdk-go/commit/bea59b886800ef555f89c47a9256d6392ed2e53d))
## 0.1.0-alpha.6 (2025-06-28)
Full Changelog: [v0.1.0-alpha.5...v0.1.0-alpha.6](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.5...v0.1.0-alpha.6)
### Bug Fixes
* don't try to deserialize as json when ResponseBodyInto is []byte ([5988d04](https://github.com/sst/opencode-sdk-go/commit/5988d04839cb78b6613057280b91b72a60fef33d))
## 0.1.0-alpha.5 (2025-06-27)
Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.4...v0.1.0-alpha.5)
### Features
* **api:** update via SDK Studio ([9e39a59](https://github.com/sst/opencode-sdk-go/commit/9e39a59b3d5d1bd5e64633732521fb28362cc70e))
## 0.1.0-alpha.4 (2025-06-27)
Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.3...v0.1.0-alpha.4)
### Features
* **api:** update via SDK Studio ([9609d1b](https://github.com/sst/opencode-sdk-go/commit/9609d1b1db7806d00cb846c9914cb4935cdedf52))
## 0.1.0-alpha.3 (2025-06-27)
Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.2...v0.1.0-alpha.3)
### Features
* **api:** update via SDK Studio ([57f3230](https://github.com/sst/opencode-sdk-go/commit/57f32309023cc1f0f20c20d02a3907e390a71f61))
## 0.1.0-alpha.2 (2025-06-27)
Full Changelog: [v0.1.0-alpha.1...v0.1.0-alpha.2](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.1...v0.1.0-alpha.2)
### Features
* **api:** update via SDK Studio ([a766f1c](https://github.com/sst/opencode-sdk-go/commit/a766f1c54f02bbc1380151b0e22d97cc2c5892e6))
## 0.1.0-alpha.1 (2025-06-27)
Full Changelog: [v0.0.1-alpha.0...v0.1.0-alpha.1](https://github.com/sst/opencode-sdk-go/compare/v0.0.1-alpha.0...v0.1.0-alpha.1)
### Features
* **api:** update via SDK Studio ([27b7376](https://github.com/sst/opencode-sdk-go/commit/27b7376310466ee17a63f2104f546b53a2b8361a))
* **api:** update via SDK Studio ([0a73e04](https://github.com/sst/opencode-sdk-go/commit/0a73e04c23c90b2061611edaa8fd6282dc0ce397))
* **api:** update via SDK Studio ([9b7883a](https://github.com/sst/opencode-sdk-go/commit/9b7883a144eeac526d9d04538e0876a9d18bb844))
+66
View File
@@ -0,0 +1,66 @@
## Setting up the environment
To set up the repository, run:
```sh
$ ./scripts/bootstrap
$ ./scripts/build
```
This will install all the required dependencies and build the SDK.
You can also [install go 1.18+ manually](https://go.dev/doc/install).
## Modifying/Adding code
Most of the SDK is generated code. Modifications to code will be persisted between generations, but may
result in merge conflicts between manual patches and changes from the generator. The generator will never
modify the contents of the `lib/` and `examples/` directories.
## Adding and running examples
All files in the `examples/` directory are not modified by the generator and can be freely edited or added to.
```go
# add an example to examples/<your-example>/main.go
package main
func main() {
// ...
}
```
```sh
$ go run ./examples/<your-example>
```
## Using the repository from source
To use a local version of this library from source in another project, edit the `go.mod` with a replace
directive. This can be done through the CLI with the following:
```sh
$ go mod edit -replace github.com/sst/opencode-sdk-go=/path/to/opencode-sdk-go
```
## Running tests
Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests.
```sh
# you will need npm installed
$ npx prism mock path/to/your/openapi.yml
```
```sh
$ ./scripts/test
```
## Formatting
This library uses the standard gofmt code formatter:
```sh
$ ./scripts/format
```
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Opencode
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+354
View File
@@ -0,0 +1,354 @@
# Opencode Go API Library
<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go"><img src="https://pkg.go.dev/badge/github.com/sst/opencode-sdk-go.svg" alt="Go Reference"></a>
The Opencode Go library provides convenient access to the [Opencode REST API](https://opencode.ai/docs)
from applications written in Go.
It is generated with [Stainless](https://www.stainless.com/).
## Installation
<!-- x-release-please-start-version -->
```go
import (
"github.com/sst/opencode-sdk-go" // imported as opencode
)
```
<!-- x-release-please-end -->
Or to pin the version:
<!-- x-release-please-start-version -->
```sh
go get -u 'github.com/sst/opencode-sdk-go@v0.1.0-alpha.8'
```
<!-- x-release-please-end -->
## Requirements
This library requires Go 1.18+.
## Usage
The full API of this library can be found in [api.md](api.md).
```go
package main
import (
"context"
"fmt"
"github.com/sst/opencode-sdk-go"
)
func main() {
client := opencode.NewClient()
events, err := client.Event.List(context.TODO())
if err != nil {
panic(err.Error())
}
fmt.Printf("%+v\n", events)
}
```
### Request fields
All request parameters are wrapped in a generic `Field` type,
which we use to distinguish zero values from null or omitted fields.
This prevents accidentally sending a zero value if you forget a required parameter,
and enables explicitly sending `null`, `false`, `''`, or `0` on optional parameters.
Any field not specified is not sent.
To construct fields with values, use the helpers `String()`, `Int()`, `Float()`, or most commonly, the generic `F[T]()`.
To send a null, use `Null[T]()`, and to send a nonconforming value, use `Raw[T](any)`. For example:
```go
params := FooParams{
Name: opencode.F("hello"),
// Explicitly send `"description": null`
Description: opencode.Null[string](),
Point: opencode.F(opencode.Point{
X: opencode.Int(0),
Y: opencode.Int(1),
// In cases where the API specifies a given type,
// but you want to send something else, use `Raw`:
Z: opencode.Raw[int64](0.01), // sends a float
}),
}
```
### Response objects
All fields in response structs are value types (not pointers or wrappers).
If a given field is `null`, not present, or invalid, the corresponding field
will simply be its zero value.
All response structs also include a special `JSON` field, containing more detailed
information about each property, which you can use like so:
```go
if res.Name == "" {
// true if `"name"` is either not present or explicitly null
res.JSON.Name.IsNull()
// true if the `"name"` key was not present in the response JSON at all
res.JSON.Name.IsMissing()
// When the API returns data that cannot be coerced to the expected type:
if res.JSON.Name.IsInvalid() {
raw := res.JSON.Name.Raw()
legacyName := struct{
First string `json:"first"`
Last string `json:"last"`
}{}
json.Unmarshal([]byte(raw), &legacyName)
name = legacyName.First + " " + legacyName.Last
}
}
```
These `.JSON` structs also include an `Extras` map containing
any properties in the json response that were not specified
in the struct. This can be useful for API features not yet
present in the SDK.
```go
body := res.JSON.ExtraFields["my_unexpected_field"].Raw()
```
### RequestOptions
This library uses the functional options pattern. Functions defined in the
`option` package return a `RequestOption`, which is a closure that mutates a
`RequestConfig`. These options can be supplied to the client or at individual
requests. For example:
```go
client := opencode.NewClient(
// Adds a header to every request made by the client
option.WithHeader("X-Some-Header", "custom_header_info"),
)
client.Event.List(context.TODO(), ...,
// Override the header
option.WithHeader("X-Some-Header", "some_other_custom_header_info"),
// Add an undocumented field to the request body, using sjson syntax
option.WithJSONSet("some.json.path", map[string]string{"my": "object"}),
)
```
See the [full list of request options](https://pkg.go.dev/github.com/sst/opencode-sdk-go/option).
### Pagination
This library provides some conveniences for working with paginated list endpoints.
You can use `.ListAutoPaging()` methods to iterate through items across all pages:
Or you can use simple `.List()` methods to fetch a single page and receive a standard response object
with additional helper methods like `.GetNextPage()`, e.g.:
### Errors
When the API returns a non-success status code, we return an error with type
`*opencode.Error`. This contains the `StatusCode`, `*http.Request`, and
`*http.Response` values of the request, as well as the JSON of the error body
(much like other response objects in the SDK).
To handle errors, we recommend that you use the `errors.As` pattern:
```go
_, err := client.Event.List(context.TODO())
if err != nil {
var apierr *opencode.Error
if errors.As(err, &apierr) {
println(string(apierr.DumpRequest(true))) // Prints the serialized HTTP request
println(string(apierr.DumpResponse(true))) // Prints the serialized HTTP response
}
panic(err.Error()) // GET "/event": 400 Bad Request { ... }
}
```
When other errors occur, they are returned unwrapped; for example,
if HTTP transport fails, you might receive `*url.Error` wrapping `*net.OpError`.
### Timeouts
Requests do not time out by default; use context to configure a timeout for a request lifecycle.
Note that if a request is [retried](#retries), the context timeout does not start over.
To set a per-retry timeout, use `option.WithRequestTimeout()`.
```go
// This sets the timeout for the request, including all the retries.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client.Event.List(
ctx,
// This sets the per-retry timeout
option.WithRequestTimeout(20*time.Second),
)
```
### File uploads
Request parameters that correspond to file uploads in multipart requests are typed as
`param.Field[io.Reader]`. The contents of the `io.Reader` will by default be sent as a multipart form
part with the file name of "anonymous_file" and content-type of "application/octet-stream".
The file name and content-type can be customized by implementing `Name() string` or `ContentType()
string` on the run-time type of `io.Reader`. Note that `os.File` implements `Name() string`, so a
file returned by `os.Open` will be sent with the file name on disk.
We also provide a helper `opencode.FileParam(reader io.Reader, filename string, contentType string)`
which can be used to wrap any `io.Reader` with the appropriate file name and content type.
### Retries
Certain errors will be automatically retried 2 times by default, with a short exponential backoff.
We retry by default all connection errors, 408 Request Timeout, 409 Conflict, 429 Rate Limit,
and >=500 Internal errors.
You can use the `WithMaxRetries` option to configure or disable this:
```go
// Configure the default for all requests:
client := opencode.NewClient(
option.WithMaxRetries(0), // default is 2
)
// Override per-request:
client.Event.List(context.TODO(), option.WithMaxRetries(5))
```
### Accessing raw response data (e.g. response headers)
You can access the raw HTTP response data by using the `option.WithResponseInto()` request option. This is useful when
you need to examine response headers, status codes, or other details.
```go
// Create a variable to store the HTTP response
var response *http.Response
events, err := client.Event.List(context.TODO(), option.WithResponseInto(&response))
if err != nil {
// handle error
}
fmt.Printf("%+v\n", events)
fmt.Printf("Status Code: %d\n", response.StatusCode)
fmt.Printf("Headers: %+#v\n", response.Header)
```
### Making custom/undocumented requests
This library is typed for convenient access to the documented API. If you need to access undocumented
endpoints, params, or response properties, the library can still be used.
#### Undocumented endpoints
To make requests to undocumented endpoints, you can use `client.Get`, `client.Post`, and other HTTP verbs.
`RequestOptions` on the client, such as retries, will be respected when making these requests.
```go
var (
// params can be an io.Reader, a []byte, an encoding/json serializable object,
// or a "…Params" struct defined in this library.
params map[string]interface{}
// result can be an []byte, *http.Response, a encoding/json deserializable object,
// or a model defined in this library.
result *http.Response
)
err := client.Post(context.Background(), "/unspecified", params, &result)
if err != nil {
}
```
#### Undocumented request params
To make requests using undocumented parameters, you may use either the `option.WithQuerySet()`
or the `option.WithJSONSet()` methods.
```go
params := FooNewParams{
ID: opencode.F("id_xxxx"),
Data: opencode.F(FooNewParamsData{
FirstName: opencode.F("John"),
}),
}
client.Foo.New(context.Background(), params, option.WithJSONSet("data.last_name", "Doe"))
```
#### Undocumented response properties
To access undocumented response properties, you may either access the raw JSON of the response as a string
with `result.JSON.RawJSON()`, or get the raw JSON of a particular field on the result with
`result.JSON.Foo.Raw()`.
Any fields that are not present on the response struct will be saved and can be accessed by `result.JSON.ExtraFields()` which returns the extra fields as a `map[string]Field`.
### Middleware
We provide `option.WithMiddleware` which applies the given
middleware to requests.
```go
func Logger(req *http.Request, next option.MiddlewareNext) (res *http.Response, err error) {
// Before the request
start := time.Now()
LogReq(req)
// Forward the request to the next handler
res, err = next(req)
// Handle stuff after the request
end := time.Now()
LogRes(res, err, start - end)
return res, err
}
client := opencode.NewClient(
option.WithMiddleware(Logger),
)
```
When multiple middlewares are provided as variadic arguments, the middlewares
are applied left to right. If `option.WithMiddleware` is given
multiple times, for example first in the client then the method, the
middleware in the client will run first and the middleware given in the method
will run next.
You may also replace the default `http.Client` with
`option.WithHTTPClient(client)`. Only one http client is
accepted (this overwrites any previous client) and receives requests after any
middleware has been applied.
## Semantic versioning
This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions:
1. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_
2. Changes that we do not expect to impact the vast majority of users in practice.
We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience.
We are keen for your feedback; please open an [issue](https://www.github.com/sst/opencode-sdk-go/issues) with questions, bugs, or suggestions.
## Contributing
See [the contributing documentation](./CONTRIBUTING.md).
+27
View File
@@ -0,0 +1,27 @@
# Security Policy
## Reporting Security Issues
This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken.
To report a security issue, please contact the Stainless team at security@stainless.com.
## Responsible Disclosure
We appreciate the efforts of security researchers and individuals who help us maintain the security of
SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible
disclosure practices by allowing us a reasonable amount of time to investigate and address the issue
before making any information public.
## Reporting Non-SDK Related Security Issues
If you encounter security issues that are not directly related to SDKs but pertain to the services
or products provided by Opencode, please follow the respective company's security reporting guidelines.
### Opencode Terms and Policies
Please contact support@sst.dev for any questions or concerns regarding the security of our services.
---
Thank you for helping us keep the SDKs and systems they interact with secure.
+34
View File
@@ -0,0 +1,34 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
package opencode
import (
"github.com/sst/opencode-sdk-go/internal/apierror"
"github.com/sst/opencode-sdk-go/shared"
)
type Error = apierror.Error
// This is an alias to an internal type.
type ProviderAuthError = shared.ProviderAuthError
// This is an alias to an internal type.
type ProviderAuthErrorData = shared.ProviderAuthErrorData
// This is an alias to an internal type.
type ProviderAuthErrorName = shared.ProviderAuthErrorName
// This is an alias to an internal value.
const ProviderAuthErrorNameProviderAuthError = shared.ProviderAuthErrorNameProviderAuthError
// This is an alias to an internal type.
type UnknownError = shared.UnknownError
// This is an alias to an internal type.
type UnknownErrorData = shared.UnknownErrorData
// This is an alias to an internal type.
type UnknownErrorName = shared.UnknownErrorName
// This is an alias to an internal value.
const UnknownErrorNameUnknownError = shared.UnknownErrorNameUnknownError
+110
View File
@@ -0,0 +1,110 @@
# Shared Response Types
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go/shared">shared</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go/shared#ProviderAuthError">ProviderAuthError</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go/shared">shared</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go/shared#UnknownError">UnknownError</a>
# Event
Response Types:
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#EventListResponse">EventListResponse</a>
Methods:
- <code title="get /event">client.Event.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#EventService.List">List</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#EventListResponse">EventListResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
# App
Response Types:
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>
Methods:
- <code title="get /app">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Get">Get</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /app/init">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
# Find
Response Types:
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindSymbolsResponse">FindSymbolsResponse</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindTextResponse">FindTextResponse</a>
Methods:
- <code title="get /find/file">client.Find.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindService.Files">Files</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, query <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindFilesParams">FindFilesParams</a>) ([]<a href="https://pkg.go.dev/builtin#string">string</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /find/symbol">client.Find.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindService.Symbols">Symbols</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, query <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindSymbolsParams">FindSymbolsParams</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindSymbolsResponse">FindSymbolsResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /find">client.Find.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindService.Text">Text</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, query <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindTextParams">FindTextParams</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FindTextResponse">FindTextResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
# File
Response Types:
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileReadResponse">FileReadResponse</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileStatusResponse">FileStatusResponse</a>
Methods:
- <code title="get /file">client.File.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileService.Read">Read</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, query <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileReadParams">FileReadParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileReadResponse">FileReadResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /file/status">client.File.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileService.Status">Status</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileStatusResponse">FileStatusResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
# Config
Response Types:
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Config">Config</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Keybinds">Keybinds</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#McpLocal">McpLocal</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#McpRemote">McpRemote</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Model">Model</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Provider">Provider</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ConfigProvidersResponse">ConfigProvidersResponse</a>
Methods:
- <code title="get /config">client.Config.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ConfigService.Get">Get</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Config">Config</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /config/providers">client.Config.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ConfigService.Providers">Providers</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ConfigProvidersResponse">ConfigProvidersResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
# Session
Params Types:
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FilePartParam">FilePartParam</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#MessagePartUnionParam">MessagePartUnionParam</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ReasoningPartParam">ReasoningPartParam</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SourceURLPartParam">SourceURLPartParam</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#StepStartPartParam">StepStartPartParam</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TextPartParam">TextPartParam</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolCallParam">ToolCallParam</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolInvocationPartParam">ToolInvocationPartParam</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolPartialCallParam">ToolPartialCallParam</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolResultParam">ToolResultParam</a>
Response Types:
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FilePart">FilePart</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Message">Message</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#MessagePart">MessagePart</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ReasoningPart">ReasoningPart</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SourceURLPart">SourceURLPart</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#StepStartPart">StepStartPart</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TextPart">TextPart</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolCall">ToolCall</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolInvocationPart">ToolInvocationPart</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolPartialCall">ToolPartialCall</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolResult">ToolResult</a>
Methods:
- <code title="post /session">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.New">New</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /session">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.List">List</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="delete /session/{id}">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Delete">Delete</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /session/{id}/abort">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Abort">Abort</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Chat">Chat</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionChatParams">SessionChatParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Message">Message</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /session/{id}/init">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionInitParams">SessionInitParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Messages">Messages</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Message">Message</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Share">Share</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /session/{id}/summarize">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Summarize">Summarize</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionSummarizeParams">SessionSummarizeParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="delete /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Unshare">Unshare</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
+123
View File
@@ -0,0 +1,123 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
package opencode
import (
"context"
"net/http"
"github.com/sst/opencode-sdk-go/internal/apijson"
"github.com/sst/opencode-sdk-go/internal/requestconfig"
"github.com/sst/opencode-sdk-go/option"
)
// AppService contains methods and other services that help with interacting with
// the opencode API.
//
// Note, unlike clients, this service does not read variables from the environment
// automatically. You should not instantiate this service directly, and instead use
// the [NewAppService] method instead.
type AppService struct {
Options []option.RequestOption
}
// NewAppService generates a new service that applies the given options to each
// request. These options are applied after the parent client's options (if there
// is one), and before any request-specific options.
func NewAppService(opts ...option.RequestOption) (r *AppService) {
r = &AppService{}
r.Options = opts
return
}
// Get app info
func (r *AppService) Get(ctx context.Context, opts ...option.RequestOption) (res *App, err error) {
opts = append(r.Options[:], opts...)
path := "app"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
return
}
// Initialize the app
func (r *AppService) Init(ctx context.Context, opts ...option.RequestOption) (res *bool, err error) {
opts = append(r.Options[:], opts...)
path := "app/init"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...)
return
}
type App struct {
Git bool `json:"git,required"`
Hostname string `json:"hostname,required"`
Path AppPath `json:"path,required"`
Time AppTime `json:"time,required"`
User string `json:"user,required"`
JSON appJSON `json:"-"`
}
// appJSON contains the JSON metadata for the struct [App]
type appJSON struct {
Git apijson.Field
Hostname apijson.Field
Path apijson.Field
Time apijson.Field
User apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *App) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r appJSON) RawJSON() string {
return r.raw
}
type AppPath struct {
Config string `json:"config,required"`
Cwd string `json:"cwd,required"`
Data string `json:"data,required"`
Root string `json:"root,required"`
State string `json:"state,required"`
JSON appPathJSON `json:"-"`
}
// appPathJSON contains the JSON metadata for the struct [AppPath]
type appPathJSON struct {
Config apijson.Field
Cwd apijson.Field
Data apijson.Field
Root apijson.Field
State apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *AppPath) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r appPathJSON) RawJSON() string {
return r.raw
}
type AppTime struct {
Initialized float64 `json:"initialized"`
JSON appTimeJSON `json:"-"`
}
// appTimeJSON contains the JSON metadata for the struct [AppTime]
type appTimeJSON struct {
Initialized apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *AppTime) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r appTimeJSON) RawJSON() string {
return r.raw
}
+58
View File
@@ -0,0 +1,58 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
package opencode_test
import (
"context"
"errors"
"os"
"testing"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode-sdk-go/internal/testutil"
"github.com/sst/opencode-sdk-go/option"
)
func TestAppGet(t *testing.T) {
t.Skip("skipped: tests are disabled for the time being")
baseURL := "http://localhost:4010"
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
baseURL = envURL
}
if !testutil.CheckTestServer(t, baseURL) {
return
}
client := opencode.NewClient(
option.WithBaseURL(baseURL),
)
_, err := client.App.Get(context.TODO())
if err != nil {
var apierr *opencode.Error
if errors.As(err, &apierr) {
t.Log(string(apierr.DumpRequest(true)))
}
t.Fatalf("err should be nil: %s", err.Error())
}
}
func TestAppInit(t *testing.T) {
t.Skip("skipped: tests are disabled for the time being")
baseURL := "http://localhost:4010"
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
baseURL = envURL
}
if !testutil.CheckTestServer(t, baseURL) {
return
}
client := opencode.NewClient(
option.WithBaseURL(baseURL),
)
_, err := client.App.Init(context.TODO())
if err != nil {
var apierr *opencode.Error
if errors.As(err, &apierr) {
t.Log(string(apierr.DumpRequest(true)))
}
t.Fatalf("err should be nil: %s", err.Error())
}
}
+123
View File
@@ -0,0 +1,123 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
package opencode
import (
"context"
"net/http"
"os"
"github.com/sst/opencode-sdk-go/internal/requestconfig"
"github.com/sst/opencode-sdk-go/option"
)
// Client creates a struct with services and top level methods that help with
// interacting with the opencode API. You should not instantiate this client
// directly, and instead use the [NewClient] method instead.
type Client struct {
Options []option.RequestOption
Event *EventService
App *AppService
Find *FindService
File *FileService
Config *ConfigService
Session *SessionService
}
// DefaultClientOptions read from the environment (OPENCODE_BASE_URL). This should
// be used to initialize new clients.
func DefaultClientOptions() []option.RequestOption {
defaults := []option.RequestOption{option.WithEnvironmentProduction()}
if o, ok := os.LookupEnv("OPENCODE_BASE_URL"); ok {
defaults = append(defaults, option.WithBaseURL(o))
}
return defaults
}
// NewClient generates a new client with the default option read from the
// environment (OPENCODE_BASE_URL). The option passed in as arguments are applied
// after these default arguments, and all option will be passed down to the
// services and requests that this client makes.
func NewClient(opts ...option.RequestOption) (r *Client) {
opts = append(DefaultClientOptions(), opts...)
r = &Client{Options: opts}
r.Event = NewEventService(opts...)
r.App = NewAppService(opts...)
r.Find = NewFindService(opts...)
r.File = NewFileService(opts...)
r.Config = NewConfigService(opts...)
r.Session = NewSessionService(opts...)
return
}
// Execute makes a request with the given context, method, URL, request params,
// response, and request options. This is useful for hitting undocumented endpoints
// while retaining the base URL, auth, retries, and other options from the client.
//
// If a byte slice or an [io.Reader] is supplied to params, it will be used as-is
// for the request body.
//
// The params is by default serialized into the body using [encoding/json]. If your
// type implements a MarshalJSON function, it will be used instead to serialize the
// request. If a URLQuery method is implemented, the returned [url.Values] will be
// used as query strings to the url.
//
// If your params struct uses [param.Field], you must provide either [MarshalJSON],
// [URLQuery], and/or [MarshalForm] functions. It is undefined behavior to use a
// struct uses [param.Field] without specifying how it is serialized.
//
// Any "…Params" object defined in this library can be used as the request
// argument. Note that 'path' arguments will not be forwarded into the url.
//
// The response body will be deserialized into the res variable, depending on its
// type:
//
// - A pointer to a [*http.Response] is populated by the raw response.
// - A pointer to a byte array will be populated with the contents of the request
// body.
// - A pointer to any other type uses this library's default JSON decoding, which
// respects UnmarshalJSON if it is defined on the type.
// - A nil value will not read the response body.
//
// For even greater flexibility, see [option.WithResponseInto] and
// [option.WithResponseBodyInto].
func (r *Client) Execute(ctx context.Context, method string, path string, params interface{}, res interface{}, opts ...option.RequestOption) error {
opts = append(r.Options, opts...)
return requestconfig.ExecuteNewRequest(ctx, method, path, params, res, opts...)
}
// Get makes a GET request with the given URL, params, and optionally deserializes
// to a response. See [Execute] documentation on the params and response.
func (r *Client) Get(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error {
return r.Execute(ctx, http.MethodGet, path, params, res, opts...)
}
// Post makes a POST request with the given URL, params, and optionally
// deserializes to a response. See [Execute] documentation on the params and
// response.
func (r *Client) Post(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error {
return r.Execute(ctx, http.MethodPost, path, params, res, opts...)
}
// Put makes a PUT request with the given URL, params, and optionally deserializes
// to a response. See [Execute] documentation on the params and response.
func (r *Client) Put(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error {
return r.Execute(ctx, http.MethodPut, path, params, res, opts...)
}
// Patch makes a PATCH request with the given URL, params, and optionally
// deserializes to a response. See [Execute] documentation on the params and
// response.
func (r *Client) Patch(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error {
return r.Execute(ctx, http.MethodPatch, path, params, res, opts...)
}
// Delete makes a DELETE request with the given URL, params, and optionally
// deserializes to a response. See [Execute] documentation on the params and
// response.
func (r *Client) Delete(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error {
return r.Execute(ctx, http.MethodDelete, path, params, res, opts...)
}
+332
View File
@@ -0,0 +1,332 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
package opencode_test
import (
"context"
"fmt"
"io"
"net/http"
"reflect"
"testing"
"time"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode-sdk-go/internal"
"github.com/sst/opencode-sdk-go/option"
)
type closureTransport struct {
fn func(req *http.Request) (*http.Response, error)
}
func (t *closureTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.fn(req)
}
func TestUserAgentHeader(t *testing.T) {
var userAgent string
client := opencode.NewClient(
option.WithHTTPClient(&http.Client{
Transport: &closureTransport{
fn: func(req *http.Request) (*http.Response, error) {
userAgent = req.Header.Get("User-Agent")
return &http.Response{
StatusCode: http.StatusOK,
}, nil
},
},
}),
)
client.Event.List(context.Background())
if userAgent != fmt.Sprintf("Opencode/Go %s", internal.PackageVersion) {
t.Errorf("Expected User-Agent to be correct, but got: %#v", userAgent)
}
}
func TestRetryAfter(t *testing.T) {
retryCountHeaders := make([]string, 0)
client := opencode.NewClient(
option.WithHTTPClient(&http.Client{
Transport: &closureTransport{
fn: func(req *http.Request) (*http.Response, error) {
retryCountHeaders = append(retryCountHeaders, req.Header.Get("X-Stainless-Retry-Count"))
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{
http.CanonicalHeaderKey("Retry-After"): []string{"0.1"},
},
}, nil
},
},
}),
)
_, err := client.Event.List(context.Background())
if err == nil {
t.Error("Expected there to be a cancel error")
}
attempts := len(retryCountHeaders)
if attempts != 3 {
t.Errorf("Expected %d attempts, got %d", 3, attempts)
}
expectedRetryCountHeaders := []string{"0", "1", "2"}
if !reflect.DeepEqual(retryCountHeaders, expectedRetryCountHeaders) {
t.Errorf("Expected %v retry count headers, got %v", expectedRetryCountHeaders, retryCountHeaders)
}
}
func TestDeleteRetryCountHeader(t *testing.T) {
retryCountHeaders := make([]string, 0)
client := opencode.NewClient(
option.WithHTTPClient(&http.Client{
Transport: &closureTransport{
fn: func(req *http.Request) (*http.Response, error) {
retryCountHeaders = append(retryCountHeaders, req.Header.Get("X-Stainless-Retry-Count"))
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{
http.CanonicalHeaderKey("Retry-After"): []string{"0.1"},
},
}, nil
},
},
}),
option.WithHeaderDel("X-Stainless-Retry-Count"),
)
_, err := client.Event.List(context.Background())
if err == nil {
t.Error("Expected there to be a cancel error")
}
expectedRetryCountHeaders := []string{"", "", ""}
if !reflect.DeepEqual(retryCountHeaders, expectedRetryCountHeaders) {
t.Errorf("Expected %v retry count headers, got %v", expectedRetryCountHeaders, retryCountHeaders)
}
}
func TestOverwriteRetryCountHeader(t *testing.T) {
retryCountHeaders := make([]string, 0)
client := opencode.NewClient(
option.WithHTTPClient(&http.Client{
Transport: &closureTransport{
fn: func(req *http.Request) (*http.Response, error) {
retryCountHeaders = append(retryCountHeaders, req.Header.Get("X-Stainless-Retry-Count"))
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{
http.CanonicalHeaderKey("Retry-After"): []string{"0.1"},
},
}, nil
},
},
}),
option.WithHeader("X-Stainless-Retry-Count", "42"),
)
_, err := client.Event.List(context.Background())
if err == nil {
t.Error("Expected there to be a cancel error")
}
expectedRetryCountHeaders := []string{"42", "42", "42"}
if !reflect.DeepEqual(retryCountHeaders, expectedRetryCountHeaders) {
t.Errorf("Expected %v retry count headers, got %v", expectedRetryCountHeaders, retryCountHeaders)
}
}
func TestRetryAfterMs(t *testing.T) {
attempts := 0
client := opencode.NewClient(
option.WithHTTPClient(&http.Client{
Transport: &closureTransport{
fn: func(req *http.Request) (*http.Response, error) {
attempts++
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{
http.CanonicalHeaderKey("Retry-After-Ms"): []string{"100"},
},
}, nil
},
},
}),
)
_, err := client.Event.List(context.Background())
if err == nil {
t.Error("Expected there to be a cancel error")
}
if want := 3; attempts != want {
t.Errorf("Expected %d attempts, got %d", want, attempts)
}
}
func TestContextCancel(t *testing.T) {
client := opencode.NewClient(
option.WithHTTPClient(&http.Client{
Transport: &closureTransport{
fn: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
},
}),
)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.Event.List(cancelCtx)
if err == nil {
t.Error("Expected there to be a cancel error")
}
}
func TestContextCancelDelay(t *testing.T) {
client := opencode.NewClient(
option.WithHTTPClient(&http.Client{
Transport: &closureTransport{
fn: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
},
}),
)
cancelCtx, cancel := context.WithTimeout(context.Background(), 2*time.Millisecond)
defer cancel()
_, err := client.Event.List(cancelCtx)
if err == nil {
t.Error("expected there to be a cancel error")
}
}
func TestContextDeadline(t *testing.T) {
testTimeout := time.After(3 * time.Second)
testDone := make(chan struct{})
deadline := time.Now().Add(100 * time.Millisecond)
deadlineCtx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
go func() {
client := opencode.NewClient(
option.WithHTTPClient(&http.Client{
Transport: &closureTransport{
fn: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
},
}),
)
_, err := client.Event.List(deadlineCtx)
if err == nil {
t.Error("expected there to be a deadline error")
}
close(testDone)
}()
select {
case <-testTimeout:
t.Fatal("client didn't finish in time")
case <-testDone:
if diff := time.Since(deadline); diff < -30*time.Millisecond || 30*time.Millisecond < diff {
t.Fatalf("client did not return within 30ms of context deadline, got %s", diff)
}
}
}
func TestContextDeadlineStreaming(t *testing.T) {
testTimeout := time.After(3 * time.Second)
testDone := make(chan struct{})
deadline := time.Now().Add(100 * time.Millisecond)
deadlineCtx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
go func() {
client := opencode.NewClient(
option.WithHTTPClient(&http.Client{
Transport: &closureTransport{
fn: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Status: "200 OK",
Body: io.NopCloser(
io.Reader(readerFunc(func([]byte) (int, error) {
<-req.Context().Done()
return 0, req.Context().Err()
})),
),
}, nil
},
},
}),
)
stream := client.Event.ListStreaming(deadlineCtx)
for stream.Next() {
_ = stream.Current()
}
if stream.Err() == nil {
t.Error("expected there to be a deadline error")
}
close(testDone)
}()
select {
case <-testTimeout:
t.Fatal("client didn't finish in time")
case <-testDone:
if diff := time.Since(deadline); diff < -30*time.Millisecond || 30*time.Millisecond < diff {
t.Fatalf("client did not return within 30ms of context deadline, got %s", diff)
}
}
}
func TestContextDeadlineStreamingWithRequestTimeout(t *testing.T) {
testTimeout := time.After(3 * time.Second)
testDone := make(chan struct{})
deadline := time.Now().Add(100 * time.Millisecond)
go func() {
client := opencode.NewClient(
option.WithHTTPClient(&http.Client{
Transport: &closureTransport{
fn: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Status: "200 OK",
Body: io.NopCloser(
io.Reader(readerFunc(func([]byte) (int, error) {
<-req.Context().Done()
return 0, req.Context().Err()
})),
),
}, nil
},
},
}),
)
stream := client.Event.ListStreaming(context.Background(), option.WithRequestTimeout((100 * time.Millisecond)))
for stream.Next() {
_ = stream.Current()
}
if stream.Err() == nil {
t.Error("expected there to be a deadline error")
}
close(testDone)
}()
select {
case <-testTimeout:
t.Fatal("client didn't finish in time")
case <-testDone:
if diff := time.Since(deadline); diff < -30*time.Millisecond || 30*time.Millisecond < diff {
t.Fatalf("client did not return within 30ms of context deadline, got %s", diff)
}
}
}
type readerFunc func([]byte) (int, error)
func (f readerFunc) Read(p []byte) (int, error) { return f(p) }
func (f readerFunc) Close() error { return nil }
+724
View File
@@ -0,0 +1,724 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
package opencode
import (
"context"
"net/http"
"reflect"
"github.com/sst/opencode-sdk-go/internal/apijson"
"github.com/sst/opencode-sdk-go/internal/requestconfig"
"github.com/sst/opencode-sdk-go/option"
"github.com/tidwall/gjson"
)
// ConfigService contains methods and other services that help with interacting
// with the opencode API.
//
// Note, unlike clients, this service does not read variables from the environment
// automatically. You should not instantiate this service directly, and instead use
// the [NewConfigService] method instead.
type ConfigService struct {
Options []option.RequestOption
}
// NewConfigService generates a new service that applies the given options to each
// request. These options are applied after the parent client's options (if there
// is one), and before any request-specific options.
func NewConfigService(opts ...option.RequestOption) (r *ConfigService) {
r = &ConfigService{}
r.Options = opts
return
}
// Get config info
func (r *ConfigService) Get(ctx context.Context, opts ...option.RequestOption) (res *Config, err error) {
opts = append(r.Options[:], opts...)
path := "config"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
return
}
// List all providers
func (r *ConfigService) Providers(ctx context.Context, opts ...option.RequestOption) (res *ConfigProvidersResponse, err error) {
opts = append(r.Options[:], opts...)
path := "config/providers"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
return
}
type Config struct {
// JSON schema reference for configuration validation
Schema string `json:"$schema"`
// Share newly created sessions automatically
Autoshare bool `json:"autoshare"`
// Automatically update to the latest version
Autoupdate bool `json:"autoupdate"`
// Disable providers that are loaded automatically
DisabledProviders []string `json:"disabled_providers"`
Experimental ConfigExperimental `json:"experimental"`
// Additional instruction files or patterns to include
Instructions []string `json:"instructions"`
// Custom keybind configurations
Keybinds Keybinds `json:"keybinds"`
// MCP (Model Context Protocol) server configurations
Mcp map[string]ConfigMcp `json:"mcp"`
// Model to use in the format of provider/model, eg anthropic/claude-2
Model string `json:"model"`
// Custom provider configurations and model overrides
Provider map[string]ConfigProvider `json:"provider"`
// Theme name to use for the interface
Theme string `json:"theme"`
JSON configJSON `json:"-"`
}
// configJSON contains the JSON metadata for the struct [Config]
type configJSON struct {
Schema apijson.Field
Autoshare apijson.Field
Autoupdate apijson.Field
DisabledProviders apijson.Field
Experimental apijson.Field
Instructions apijson.Field
Keybinds apijson.Field
Mcp apijson.Field
Model apijson.Field
Provider apijson.Field
Theme apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *Config) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configJSON) RawJSON() string {
return r.raw
}
type ConfigExperimental struct {
Hook ConfigExperimentalHook `json:"hook"`
JSON configExperimentalJSON `json:"-"`
}
// configExperimentalJSON contains the JSON metadata for the struct
// [ConfigExperimental]
type configExperimentalJSON struct {
Hook apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigExperimental) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configExperimentalJSON) RawJSON() string {
return r.raw
}
type ConfigExperimentalHook struct {
FileEdited map[string][]ConfigExperimentalHookFileEdited `json:"file_edited"`
SessionCompleted []ConfigExperimentalHookSessionCompleted `json:"session_completed"`
JSON configExperimentalHookJSON `json:"-"`
}
// configExperimentalHookJSON contains the JSON metadata for the struct
// [ConfigExperimentalHook]
type configExperimentalHookJSON struct {
FileEdited apijson.Field
SessionCompleted apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigExperimentalHook) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configExperimentalHookJSON) RawJSON() string {
return r.raw
}
type ConfigExperimentalHookFileEdited struct {
Command []string `json:"command,required"`
Environment map[string]string `json:"environment"`
JSON configExperimentalHookFileEditedJSON `json:"-"`
}
// configExperimentalHookFileEditedJSON contains the JSON metadata for the struct
// [ConfigExperimentalHookFileEdited]
type configExperimentalHookFileEditedJSON struct {
Command apijson.Field
Environment apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigExperimentalHookFileEdited) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configExperimentalHookFileEditedJSON) RawJSON() string {
return r.raw
}
type ConfigExperimentalHookSessionCompleted struct {
Command []string `json:"command,required"`
Environment map[string]string `json:"environment"`
JSON configExperimentalHookSessionCompletedJSON `json:"-"`
}
// configExperimentalHookSessionCompletedJSON contains the JSON metadata for the
// struct [ConfigExperimentalHookSessionCompleted]
type configExperimentalHookSessionCompletedJSON struct {
Command apijson.Field
Environment apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigExperimentalHookSessionCompleted) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configExperimentalHookSessionCompletedJSON) RawJSON() string {
return r.raw
}
type ConfigMcp struct {
// Type of MCP server connection
Type ConfigMcpType `json:"type,required"`
// This field can have the runtime type of [[]string].
Command interface{} `json:"command"`
// Enable or disable the MCP server on startup
Enabled bool `json:"enabled"`
// This field can have the runtime type of [map[string]string].
Environment interface{} `json:"environment"`
// URL of the remote MCP server
URL string `json:"url"`
JSON configMcpJSON `json:"-"`
union ConfigMcpUnion
}
// configMcpJSON contains the JSON metadata for the struct [ConfigMcp]
type configMcpJSON struct {
Type apijson.Field
Command apijson.Field
Enabled apijson.Field
Environment apijson.Field
URL apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r configMcpJSON) RawJSON() string {
return r.raw
}
func (r *ConfigMcp) UnmarshalJSON(data []byte) (err error) {
*r = ConfigMcp{}
err = apijson.UnmarshalRoot(data, &r.union)
if err != nil {
return err
}
return apijson.Port(r.union, &r)
}
// AsUnion returns a [ConfigMcpUnion] interface which you can cast to the specific
// types for more type safety.
//
// Possible runtime types of the union are [McpLocal], [McpRemote].
func (r ConfigMcp) AsUnion() ConfigMcpUnion {
return r.union
}
// Union satisfied by [McpLocal] or [McpRemote].
type ConfigMcpUnion interface {
implementsConfigMcp()
}
func init() {
apijson.RegisterUnion(
reflect.TypeOf((*ConfigMcpUnion)(nil)).Elem(),
"type",
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(McpLocal{}),
DiscriminatorValue: "local",
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(McpRemote{}),
DiscriminatorValue: "remote",
},
)
}
// Type of MCP server connection
type ConfigMcpType string
const (
ConfigMcpTypeLocal ConfigMcpType = "local"
ConfigMcpTypeRemote ConfigMcpType = "remote"
)
func (r ConfigMcpType) IsKnown() bool {
switch r {
case ConfigMcpTypeLocal, ConfigMcpTypeRemote:
return true
}
return false
}
type ConfigProvider struct {
Models map[string]ConfigProviderModel `json:"models,required"`
ID string `json:"id"`
API string `json:"api"`
Env []string `json:"env"`
Name string `json:"name"`
Npm string `json:"npm"`
Options map[string]interface{} `json:"options"`
JSON configProviderJSON `json:"-"`
}
// configProviderJSON contains the JSON metadata for the struct [ConfigProvider]
type configProviderJSON struct {
Models apijson.Field
ID apijson.Field
API apijson.Field
Env apijson.Field
Name apijson.Field
Npm apijson.Field
Options apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigProvider) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configProviderJSON) RawJSON() string {
return r.raw
}
type ConfigProviderModel struct {
ID string `json:"id"`
Attachment bool `json:"attachment"`
Cost ConfigProviderModelsCost `json:"cost"`
Limit ConfigProviderModelsLimit `json:"limit"`
Name string `json:"name"`
Options map[string]interface{} `json:"options"`
Reasoning bool `json:"reasoning"`
ReleaseDate string `json:"release_date"`
Temperature bool `json:"temperature"`
ToolCall bool `json:"tool_call"`
JSON configProviderModelJSON `json:"-"`
}
// configProviderModelJSON contains the JSON metadata for the struct
// [ConfigProviderModel]
type configProviderModelJSON struct {
ID apijson.Field
Attachment apijson.Field
Cost apijson.Field
Limit apijson.Field
Name apijson.Field
Options apijson.Field
Reasoning apijson.Field
ReleaseDate apijson.Field
Temperature apijson.Field
ToolCall apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigProviderModel) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configProviderModelJSON) RawJSON() string {
return r.raw
}
type ConfigProviderModelsCost struct {
Input float64 `json:"input,required"`
Output float64 `json:"output,required"`
CacheRead float64 `json:"cache_read"`
CacheWrite float64 `json:"cache_write"`
JSON configProviderModelsCostJSON `json:"-"`
}
// configProviderModelsCostJSON contains the JSON metadata for the struct
// [ConfigProviderModelsCost]
type configProviderModelsCostJSON struct {
Input apijson.Field
Output apijson.Field
CacheRead apijson.Field
CacheWrite apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigProviderModelsCost) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configProviderModelsCostJSON) RawJSON() string {
return r.raw
}
type ConfigProviderModelsLimit struct {
Context float64 `json:"context,required"`
Output float64 `json:"output,required"`
JSON configProviderModelsLimitJSON `json:"-"`
}
// configProviderModelsLimitJSON contains the JSON metadata for the struct
// [ConfigProviderModelsLimit]
type configProviderModelsLimitJSON struct {
Context apijson.Field
Output apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigProviderModelsLimit) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configProviderModelsLimitJSON) RawJSON() string {
return r.raw
}
type Keybinds struct {
// Exit the application
AppExit string `json:"app_exit"`
// Open external editor
EditorOpen string `json:"editor_open"`
// Show help dialog
Help string `json:"help"`
// Navigate to next history item
HistoryNext string `json:"history_next"`
// Navigate to previous history item
HistoryPrevious string `json:"history_previous"`
// Clear input field
InputClear string `json:"input_clear"`
// Insert newline in input
InputNewline string `json:"input_newline"`
// Paste from clipboard
InputPaste string `json:"input_paste"`
// Submit input
InputSubmit string `json:"input_submit"`
// Leader key for keybind combinations
Leader string `json:"leader"`
// Navigate to first message
MessagesFirst string `json:"messages_first"`
// Scroll messages down by half page
MessagesHalfPageDown string `json:"messages_half_page_down"`
// Scroll messages up by half page
MessagesHalfPageUp string `json:"messages_half_page_up"`
// Navigate to last message
MessagesLast string `json:"messages_last"`
// Navigate to next message
MessagesNext string `json:"messages_next"`
// Scroll messages down by one page
MessagesPageDown string `json:"messages_page_down"`
// Scroll messages up by one page
MessagesPageUp string `json:"messages_page_up"`
// Navigate to previous message
MessagesPrevious string `json:"messages_previous"`
// List available models
ModelList string `json:"model_list"`
// Initialize project configuration
ProjectInit string `json:"project_init"`
// Toggle compact mode for session
SessionCompact string `json:"session_compact"`
// Interrupt current session
SessionInterrupt string `json:"session_interrupt"`
// List all sessions
SessionList string `json:"session_list"`
// Create a new session
SessionNew string `json:"session_new"`
// Share current session
SessionShare string `json:"session_share"`
// List available themes
ThemeList string `json:"theme_list"`
// Show tool details
ToolDetails string `json:"tool_details"`
JSON keybindsJSON `json:"-"`
}
// keybindsJSON contains the JSON metadata for the struct [Keybinds]
type keybindsJSON struct {
AppExit apijson.Field
EditorOpen apijson.Field
Help apijson.Field
HistoryNext apijson.Field
HistoryPrevious apijson.Field
InputClear apijson.Field
InputNewline apijson.Field
InputPaste apijson.Field
InputSubmit apijson.Field
Leader apijson.Field
MessagesFirst apijson.Field
MessagesHalfPageDown apijson.Field
MessagesHalfPageUp apijson.Field
MessagesLast apijson.Field
MessagesNext apijson.Field
MessagesPageDown apijson.Field
MessagesPageUp apijson.Field
MessagesPrevious apijson.Field
ModelList apijson.Field
ProjectInit apijson.Field
SessionCompact apijson.Field
SessionInterrupt apijson.Field
SessionList apijson.Field
SessionNew apijson.Field
SessionShare apijson.Field
ThemeList apijson.Field
ToolDetails apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *Keybinds) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r keybindsJSON) RawJSON() string {
return r.raw
}
type McpLocal struct {
// Command and arguments to run the MCP server
Command []string `json:"command,required"`
// Type of MCP server connection
Type McpLocalType `json:"type,required"`
// Enable or disable the MCP server on startup
Enabled bool `json:"enabled"`
// Environment variables to set when running the MCP server
Environment map[string]string `json:"environment"`
JSON mcpLocalJSON `json:"-"`
}
// mcpLocalJSON contains the JSON metadata for the struct [McpLocal]
type mcpLocalJSON struct {
Command apijson.Field
Type apijson.Field
Enabled apijson.Field
Environment apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *McpLocal) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r mcpLocalJSON) RawJSON() string {
return r.raw
}
func (r McpLocal) implementsConfigMcp() {}
// Type of MCP server connection
type McpLocalType string
const (
McpLocalTypeLocal McpLocalType = "local"
)
func (r McpLocalType) IsKnown() bool {
switch r {
case McpLocalTypeLocal:
return true
}
return false
}
type McpRemote struct {
// Type of MCP server connection
Type McpRemoteType `json:"type,required"`
// URL of the remote MCP server
URL string `json:"url,required"`
// Enable or disable the MCP server on startup
Enabled bool `json:"enabled"`
JSON mcpRemoteJSON `json:"-"`
}
// mcpRemoteJSON contains the JSON metadata for the struct [McpRemote]
type mcpRemoteJSON struct {
Type apijson.Field
URL apijson.Field
Enabled apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *McpRemote) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r mcpRemoteJSON) RawJSON() string {
return r.raw
}
func (r McpRemote) implementsConfigMcp() {}
// Type of MCP server connection
type McpRemoteType string
const (
McpRemoteTypeRemote McpRemoteType = "remote"
)
func (r McpRemoteType) IsKnown() bool {
switch r {
case McpRemoteTypeRemote:
return true
}
return false
}
type Model struct {
ID string `json:"id,required"`
Attachment bool `json:"attachment,required"`
Cost ModelCost `json:"cost,required"`
Limit ModelLimit `json:"limit,required"`
Name string `json:"name,required"`
Options map[string]interface{} `json:"options,required"`
Reasoning bool `json:"reasoning,required"`
ReleaseDate string `json:"release_date,required"`
Temperature bool `json:"temperature,required"`
ToolCall bool `json:"tool_call,required"`
JSON modelJSON `json:"-"`
}
// modelJSON contains the JSON metadata for the struct [Model]
type modelJSON struct {
ID apijson.Field
Attachment apijson.Field
Cost apijson.Field
Limit apijson.Field
Name apijson.Field
Options apijson.Field
Reasoning apijson.Field
ReleaseDate apijson.Field
Temperature apijson.Field
ToolCall apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *Model) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r modelJSON) RawJSON() string {
return r.raw
}
type ModelCost struct {
Input float64 `json:"input,required"`
Output float64 `json:"output,required"`
CacheRead float64 `json:"cache_read"`
CacheWrite float64 `json:"cache_write"`
JSON modelCostJSON `json:"-"`
}
// modelCostJSON contains the JSON metadata for the struct [ModelCost]
type modelCostJSON struct {
Input apijson.Field
Output apijson.Field
CacheRead apijson.Field
CacheWrite apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ModelCost) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r modelCostJSON) RawJSON() string {
return r.raw
}
type ModelLimit struct {
Context float64 `json:"context,required"`
Output float64 `json:"output,required"`
JSON modelLimitJSON `json:"-"`
}
// modelLimitJSON contains the JSON metadata for the struct [ModelLimit]
type modelLimitJSON struct {
Context apijson.Field
Output apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ModelLimit) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r modelLimitJSON) RawJSON() string {
return r.raw
}
type Provider struct {
ID string `json:"id,required"`
Env []string `json:"env,required"`
Models map[string]Model `json:"models,required"`
Name string `json:"name,required"`
API string `json:"api"`
Npm string `json:"npm"`
JSON providerJSON `json:"-"`
}
// providerJSON contains the JSON metadata for the struct [Provider]
type providerJSON struct {
ID apijson.Field
Env apijson.Field
Models apijson.Field
Name apijson.Field
API apijson.Field
Npm apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *Provider) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r providerJSON) RawJSON() string {
return r.raw
}
type ConfigProvidersResponse struct {
Default map[string]string `json:"default,required"`
Providers []Provider `json:"providers,required"`
JSON configProvidersResponseJSON `json:"-"`
}
// configProvidersResponseJSON contains the JSON metadata for the struct
// [ConfigProvidersResponse]
type configProvidersResponseJSON struct {
Default apijson.Field
Providers apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigProvidersResponse) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configProvidersResponseJSON) RawJSON() string {
return r.raw
}
+58
View File
@@ -0,0 +1,58 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
package opencode_test
import (
"context"
"errors"
"os"
"testing"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode-sdk-go/internal/testutil"
"github.com/sst/opencode-sdk-go/option"
)
func TestConfigGet(t *testing.T) {
t.Skip("skipped: tests are disabled for the time being")
baseURL := "http://localhost:4010"
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
baseURL = envURL
}
if !testutil.CheckTestServer(t, baseURL) {
return
}
client := opencode.NewClient(
option.WithBaseURL(baseURL),
)
_, err := client.Config.Get(context.TODO())
if err != nil {
var apierr *opencode.Error
if errors.As(err, &apierr) {
t.Log(string(apierr.DumpRequest(true)))
}
t.Fatalf("err should be nil: %s", err.Error())
}
}
func TestConfigProviders(t *testing.T) {
t.Skip("skipped: tests are disabled for the time being")
baseURL := "http://localhost:4010"
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
baseURL = envURL
}
if !testutil.CheckTestServer(t, baseURL) {
return
}
client := opencode.NewClient(
option.WithBaseURL(baseURL),
)
_, err := client.Config.Providers(context.TODO())
if err != nil {
var apierr *opencode.Error
if errors.As(err, &apierr) {
t.Log(string(apierr.DumpRequest(true)))
}
t.Fatalf("err should be nil: %s", err.Error())
}
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
File generated from our OpenAPI spec by Stainless.
This directory can be used to store example files demonstrating usage of this SDK.
It is ignored by Stainless code generation and its content (other than this keep file) won't be touched.

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