Compare commits

..

222 Commits

Author SHA1 Message Date
opencode fbad378966 release: v1.0.145 2025-12-11 06:19:42 +00:00
Dax Raad c211b22a45 update 2025-12-11 01:15:34 -05:00
Dax Raad 5d57d0385c opencode config 2025-12-11 01:13:23 -05:00
Aiden Cline f7e29a1acf downgrade bun 2025-12-11 00:10:58 -06:00
Aiden Cline a8f83cdcb5 ignore: comment out item in project cfg 2025-12-11 00:09:43 -06:00
Github Action 9e0a2bc7d0 Update Nix flake.lock and hashes 2025-12-11 06:03:16 +00:00
Dax Raad 2d9c76baae enterprise: add default social card images to HTML head
- Add og:image and twitter:image meta tags to entry-server
- Provide fallback social card image for pages without specific social cards
2025-12-11 01:02:31 -05:00
Dax Raad 85c01e8694 enterprise: add social card meta tags to share pages
- Add og:image and twitter:image meta tags for better social sharing
- Generate dynamic social card URLs with session title, models, and version
- Include description meta tag for search engines
2025-12-11 01:01:32 -05:00
GitHub Action 65a6b3d585 chore: format code 2025-12-11 05:54:58 +00:00
Dax Raad 40f121c3e8 prevent indexing of share page 2025-12-11 00:54:15 -05:00
opencode 6251231e41 release: v1.0.144 2025-12-11 05:48:13 +00:00
Dax Raad 578072bb8e use new share url 2025-12-11 00:43:00 -05:00
Dax Raad 231390cb7b ci 2025-12-11 00:23:06 -05:00
Dax Raad 5955d20539 remove 2025-12-11 00:21:40 -05:00
Dax Raad 4309c078fb domain 2025-12-11 00:11:40 -05:00
Dax Raad d14462f7a7 fix 2025-12-10 23:19:28 -05:00
Dax Raad a02223a310 sync 2025-12-10 23:19:28 -05:00
Ayush Walekar d93c8c7604 docs: update doc sdk.mdx (#5315) 2025-12-10 21:56:23 -06:00
Aiden Cline 7eb509db14 ci: rm bash tool from opencode ci workflow, reduce risks 2025-12-10 21:45:46 -06:00
Dax Raad f1b8707286 ignore 2025-12-10 22:36:57 -05:00
Dax Raad 9b05217471 ignore 2025-12-10 22:34:16 -05:00
Dax Raad d88912abf0 global bus 2025-12-10 22:28:56 -05:00
GitHub Action 28c6320cd6 chore: format code 2025-12-11 03:22:51 +00:00
Dax Raad 13a77005f1 global.dispose 2025-12-10 22:22:16 -05:00
Dax Raad 530b75a92a ignore 2025-12-10 22:22:16 -05:00
Aiden Cline 7b4f852f33 ignore: tmp transform exclusion 2025-12-10 21:18:19 -06:00
Aiden Cline 439aebb4e9 tweak: correct thinkingLevel 2025-12-10 21:13:10 -06:00
Brendan Allan 6f5f73a74a desktop: exclude ts-dist from tsconfig sources 2025-12-11 09:51:35 +08:00
Brendan Allan bd1f5f884e tauri: update icons 2025-12-11 09:49:00 +08:00
Koichi Nakayamada 499ad4f84b fix: light mode visibility for filepath in /undo diff files (#5352) 2025-12-10 19:38:14 -06:00
Jinhyeok Lee 01fd0d8209 docs(bash): clarify description parameter is required (#5353) 2025-12-10 19:37:38 -06:00
Seb Duerr df55ad89ab Add Cerebras integration header with opencode identifier (#5354) 2025-12-10 19:36:58 -06:00
Dax Raad fadeed1fa4 desktop: enable zoom hotkeys in Tauri app 2025-12-10 19:27:05 -05:00
Dax Raad 13611176b0 fix deploy 2025-12-10 19:11:49 -05:00
Jay V 92fa66d76f core: reposition OpenCode as open source multi-platform coding agent
docs: update main intro page to reflect open source positioning and multi-platform availability
2025-12-10 19:05:33 -05:00
Jay V 1a1874d8b3 docs: desktop 2025-12-10 18:43:19 -05:00
Adam 56540f8312 wip(desktop): progress 2025-12-10 17:31:13 -06:00
Shantur Rathore 89d51ad596 compaction: improve compaction prompt (#5348) 2025-12-10 17:21:38 -06:00
Adam 15b8c14542 fix: tauri 2025-12-10 17:19:50 -06:00
Adam 85cfa226c3 wip(desktop): progress 2025-12-10 17:17:37 -06:00
Christian Stewart cbb591eb7d fix: more descriptive tool or subtask execution failed error (#5337)
Signed-off-by: Christian Stewart <christian@aperture.us>
2025-12-10 17:12:49 -06:00
Aiden Cline e36c349222 tweak: oc -> OC 2025-12-10 17:06:16 -06:00
Christian Stewart b274371dbb feat: use |- for intermediate sub-agent steps (#5336)
Signed-off-by: Christian Stewart <christian@aperture.us>
2025-12-10 16:36:11 -06:00
Hammad Shami 72eb004057 feat: add helicone docs + helicone session tracking (#5265) 2025-12-10 16:23:52 -06:00
Yukai Huang e46080aa8c fix(auth): add plugin lookup for custom provider in 'Other' flow (#5324) 2025-12-10 16:23:12 -06:00
Aiden Cline 7d82f1769c tweak: small fix 2025-12-10 16:01:10 -06:00
OpeOginni 7435d94f85 fix(cli): obtain directory data from server (#5320) 2025-12-10 15:55:15 -06:00
Github Action e060f968f5 Update Nix flake.lock and hashes 2025-12-10 21:18:57 +00:00
Dax Raad 86f7cc17ae tui: pass dynamic port to frontend
Frontend now receives the server port via window.__OPENCODE__.port,
allowing it to connect when using a random free port instead of hardcoded 4096
2025-12-10 16:17:36 -05:00
Adam 58e66dd3d1 wip(desktop): progress 2025-12-10 15:17:04 -06:00
Adam 190fa4c87a wip(desktop): progress 2025-12-10 15:17:03 -06:00
Adam 91d743ef9a wip(desktop): progress 2025-12-10 15:17:03 -06:00
Adam 804ad5897f wip(desktop): progress 2025-12-10 15:17:03 -06:00
Adam f20d6e8555 wip(desktop): progress 2025-12-10 15:17:03 -06:00
Adam e694d4d880 wip(desktop): progress 2025-12-10 15:17:02 -06:00
Adam ada40decd1 wip(desktop): progress 2025-12-10 15:17:02 -06:00
Adam 6866a060bc wip(desktop): progress 2025-12-10 15:17:02 -06:00
Adam a4ec619c74 wip(desktop): progress 2025-12-10 15:17:02 -06:00
Adam 67a95c3cc8 wip(desktop): progress 2025-12-10 15:17:01 -06:00
Dax Raad 8d3eac2347 fix type 2025-12-10 16:14:32 -05:00
Dax Raad 9ad828dcd0 tui: use random free port and enable icon discovery by default
- Tauri app now automatically finds an available port instead of defaulting to 4096
- Icon discovery feature is now enabled by default in the Tauri app
- Prevents port conflicts when multiple OpenCode instances are running
2025-12-10 16:13:11 -05:00
Aiden Cline 59fb3ae606 ignore: add bash tests 2025-12-10 15:07:40 -06:00
GitHub Action 0ab3b88250 chore: format code 2025-12-10 21:01:06 +00:00
Dax Raad a1175bddcd gen types 2025-12-10 15:59:42 -05:00
Dax Raad 936a6be5d6 stuff adam needs 2025-12-10 15:59:42 -05:00
igordertigor 03c6c3f4cb docs: document accept always behavior (#5340)
Co-authored-by: Ingo Fruend <ingo@oudyo.com>
2025-12-10 14:59:12 -06:00
Aiden Cline 6288a032fd bump bun to 1.3.4 2025-12-10 14:48:52 -06:00
Daniel Polito 31e6ed6806 Add Eyes Reaction instead of Comment Working on Github Action (#5072) 2025-12-10 13:35:55 -06:00
Aiden Cline da56319af4 ignore: fix test 2025-12-10 13:31:45 -06:00
GitHub Action 2198f9400f chore: format code 2025-12-10 19:31:11 +00:00
Dax Raad ffc4d53923 add models.dev family 2025-12-10 14:30:28 -05:00
Aiden Cline 18d3c054a3 more interleaved thinking fixes (#5334) 2025-12-10 13:29:26 -06:00
Aiden Cline 59c5da9b6c tweak: set gemini thinkingLevel to high by default 2025-12-10 13:09:27 -06:00
Github Action 15880195a2 Update Nix flake.lock and hashes 2025-12-10 19:04:03 +00:00
Tom Aylott 117de64f39 fix: upgrade OpenRouter sdk pkg (#5302)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-10 13:02:26 -06:00
Dax Raad 388156704a fix /provider endpoint to return loaded providers 2025-12-10 13:49:45 -05:00
GitHub Action faf443132f chore: format code 2025-12-10 18:32:21 +00:00
Nick 36a9be040b docs: add opencode-type-inject to ecosystem plugins (#5331) 2025-12-10 12:31:44 -06:00
GitHub Action 1835d7526f chore: format code 2025-12-10 16:55:19 +00:00
Stanislas 946e4f0a61 docs: add wakatime plugin to ecosystem page (#5326)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-10 10:54:45 -06:00
Matt Silverlock ae60f41adf themes: update orng theme (#5329) 2025-12-10 10:52:58 -06:00
GitHub Action 6b93d23642 chore: format code 2025-12-10 16:50:49 +00:00
Noè cfa13df346 docs: Added opencode-antigravity-auth to ecosystem documentation (#5303) 2025-12-10 10:50:12 -06:00
Frank 744a7159e4 zen: sync 2025-12-10 11:44:37 -05:00
arc-source-coder 80d1c62818 tui: only show active MCP server count (#5327) 2025-12-10 10:27:59 -06:00
Connor Adams 83aa42f510 docs: configure mise to use latest version instead of pinned version (#5316) 2025-12-10 10:11:48 -06:00
Aiden Cline 183a1a181c ci: stop zed sync temporarily 2025-12-10 09:57:34 -06:00
Github Action bc7e7c2c4d Update Nix flake.lock and hashes 2025-12-10 14:04:52 +00:00
GitHub Action 7b5bd89570 chore: format code 2025-12-10 14:04:08 +00:00
Sebastian Herrlinger ba1c6122b9 bump opentui to v0.1.60, fixing doubled key events on some older terminal emulators and add_buffer leaks for prompt input 2025-12-10 15:03:14 +01:00
Sebastian Herrlinger baed581a7c remove input_forward_delete special handling 2025-12-10 14:55:56 +01:00
GitHub Action 4a23052778 chore: format code 2025-12-10 13:53:21 +00:00
opencode ee4190aa41 release: v1.0.143 2025-12-10 13:53:21 +00:00
Dax Raad de8460cb99 docs: improve bash and grep tool documentation with clearer usage guidelines 2025-12-10 08:48:41 -05:00
opencode f7b2beaaf1 release: v1.0.142 2025-12-10 13:25:55 +00:00
GitHub Action 9b0933187e ignore: update download stats 2025-12-10 2025-12-10 12:04:47 +00:00
Adam 862141e8b2 fix: exit aliases 2025-12-10 02:49:54 -06:00
Aiden Cline 070ced0b3f fix: revert hook try/catch that surpressed errors 2025-12-10 00:14:24 -06:00
GitHub Action cc3b699823 chore: format code 2025-12-10 06:02:31 +00:00
spoj 301f1a191b fix: add Windows support for shell mode (! command) (#5311) 2025-12-10 00:01:56 -06:00
Adam d149c25aab fix: types 2025-12-09 21:44:34 -06:00
Adam 18d24b8f5f wip(desktop): progress 2025-12-09 21:39:13 -06:00
Adam cf34981e8f wip(desktop): progress 2025-12-09 21:39:13 -06:00
Adam e2ebe560ea feat: provider icon component 2025-12-09 21:39:12 -06:00
GitHub Action 6db822fd92 chore: format code 2025-12-10 03:32:26 +00:00
Brendan Allan 661122bab8 tauri: don't ask to restart separately in updater 2025-12-10 11:31:50 +08:00
Brendan Allan 4a96836d11 tauri: update macos icon 2025-12-10 11:28:40 +08:00
Brendan Allan e072f9605c tauri: comment out restart server dialog 2025-12-10 11:18:17 +08:00
Brendan Allan 9986031481 fix: use project references for desktop typecheck 2025-12-10 11:15:12 +08:00
Dax Raad 3d95848607 ci 2025-12-09 22:14:18 -05:00
GitHub Action 221c9028af chore: format code 2025-12-10 03:10:18 +00:00
Timor b2057791aa feat: add CLI arguments to agent create command for scripting (#5157) 2025-12-09 21:09:45 -06:00
Dax Raad c1ee6d6c41 ci 2025-12-10 02:57:39 +00:00
opencode a3fbbece9a release: v1.0.141 2025-12-10 02:57:38 +00:00
Dax Raad e72c974c4c ci 2025-12-09 21:49:38 -05:00
Dax Raad a762da7cab ci 2025-12-09 21:49:05 -05:00
Dax Raad fa6c060324 ci 2025-12-09 21:44:13 -05:00
Dax Raad 8e33ac052b ci: publish with multiple tags instead of using dist-tag
npm dist-tag add command is broken, so publish package multiple times
with different tags directly instead
2025-12-09 21:39:05 -05:00
Dax Raad 0759696ec0 core: enable project discovery for experimental builds 2025-12-09 21:23:55 -05:00
Hosenur Rahaman 59dce63471 docs: Add portal project to ecosystem documentation (#5300)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-09 20:23:14 -06:00
Dax Raad 1ae28090e3 ci 2025-12-09 21:16:36 -05:00
Dax Raad 0decdf6a55 ci 2025-12-09 21:16:23 -05:00
Dax Raad 09b402a274 ci 2025-12-09 21:16:00 -05:00
Dax Raad 150baf3e96 ci 2025-12-09 21:10:58 -05:00
Dax Raad 78c51371af sync 2025-12-09 21:06:57 -05:00
Dax Raad 6dbcacf3ea ci 2025-12-09 21:04:06 -05:00
Dax Raad 4ecebc2c83 ci 2025-12-09 21:03:19 -05:00
Dax Raad 38a79fa449 ci 2025-12-09 21:02:04 -05:00
Dax Raad bafad6b8a8 ci 2025-12-09 21:01:48 -05:00
Dax Raad 5682dddd45 ci 2025-12-09 21:01:39 -05:00
Dax Raad a9aacdb94a ci 2025-12-09 20:59:31 -05:00
Dax Raad e7e32c946b ci 2025-12-09 20:58:01 -05:00
Dax Raad fc9bc26d86 ci 2025-12-09 20:56:13 -05:00
Dax Raad ee00b4e0ce ci 2025-12-09 20:54:56 -05:00
Dax Raad f82156f0b1 ci 2025-12-09 20:51:54 -05:00
Dax Raad 2ed6298584 ci 2025-12-09 20:51:25 -05:00
Dax Raad 52ef8dea3e ci 2025-12-09 20:50:46 -05:00
Adam ebe6015db0 fix: re-enable tauri typecheck 2025-12-09 19:34:07 -06:00
Dax Raad 56526114e4 ci 2025-12-09 20:26:46 -05:00
Dax Raad bb1c225027 ci 2025-12-09 20:24:31 -05:00
Dax Raad e5af0dde08 ci 2025-12-09 20:22:00 -05:00
Dax Raad 3cf17bc24f ci 2025-12-09 20:20:47 -05:00
Dax Raad 4aa1b8de0e ci 2025-12-09 20:19:43 -05:00
Dax Raad 73e9534d08 ci 2025-12-09 20:17:07 -05:00
Dax Raad cb188f907f ci 2025-12-09 19:53:50 -05:00
Dax Raad 63d9656ad8 ci 2025-12-09 19:46:59 -05:00
Dax Raad 3512d02e9e ci 2025-12-09 19:46:38 -05:00
Dax Raad 1efdceaf10 ci: combine sdk and format workflows into single generate workflow 2025-12-09 19:41:23 -05:00
GitHub Action 632a0fe009 chore: regen sdk 2025-12-10 00:33:05 +00:00
Dax Raad 6fb32cebec ci 2025-12-09 19:32:30 -05:00
GitHub Action 8b8b17d755 chore: format code 2025-12-10 00:28:05 +00:00
GitHub Action 2c27afaaf5 chore: regen sdk 2025-12-10 00:27:29 +00:00
Dax Raad 4bdc7c1426 ci fix 2025-12-09 19:26:54 -05:00
Github Action 3c1e6c2c8f Update Nix flake.lock and hashes 2025-12-09 23:56:13 +00:00
Aiden Cline b8f5809f95 ignore: rm chalk 2025-12-09 17:54:53 -06:00
Aiden Cline 552ee81455 tweak: add OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS 2025-12-09 17:28:34 -06:00
David Hill 9fdbe193cd fix: add spacer before queued badge 2025-12-09 22:56:44 +00:00
Aiden Cline df64612d54 better interleaved thinking support (#5298) 2025-12-09 16:32:12 -06:00
Adam 0aa3e6c270 wip(desktop): progress 2025-12-09 16:23:05 -06:00
Adam 44c17c1435 wip(desktop): progress 2025-12-09 16:23:05 -06:00
Dax Raad 132e772c26 core: fix project icon update handling to preserve existing icon properties 2025-12-09 16:55:26 -05:00
Adam 62cbed57cc wip(desktop): progress 2025-12-09 15:55:08 -06:00
Adam ebab7e176e wip(desktop): progress 2025-12-09 15:53:08 -06:00
Adam 9c93853e22 wip(desktop): progress 2025-12-09 15:46:23 -06:00
Aiden Cline 8a9c7a4ef3 add OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT 2025-12-09 15:44:56 -06:00
Adam 2dad56c9a2 wip(desktop): progress 2025-12-09 15:39:44 -06:00
Adam 41d78c1ecc wip(desktop): progress 2025-12-09 15:32:36 -06:00
ry2009 16c4b02b69 feat: add Biome LSP support (#5290) 2025-12-09 15:31:13 -06:00
Adam 35c04d9283 wip(desktop): progress 2025-12-09 15:24:11 -06:00
Adam 1fbd7a7f9a wip(desktop): progress 2025-12-09 15:21:47 -06:00
Adam d7563d1694 wip(desktop): progress 2025-12-09 15:21:47 -06:00
Adam b9fa7d9163 wip(desktop): progress 2025-12-09 15:21:47 -06:00
Adam f736751ab2 wip(desktop): progress 2025-12-09 15:21:45 -06:00
Aiden Cline dbcc779f0b ci: fix sdk workflow 2025-12-09 15:08:30 -06:00
Nick c33a90320c fix: resolve 'latest' to actual version when caching plugins (#5292) 2025-12-09 15:07:59 -06:00
Dax Raad 802b862aae format on write 2025-12-09 16:06:57 -05:00
GitHub Action b0cd171c1b chore: format code 2025-12-09 21:05:09 +00:00
GitHub Action 13755f4680 chore: regen sdk 2025-12-09 21:04:37 +00:00
Dax Raad b242659cc3 fix types 2025-12-09 16:03:58 -05:00
Dax Raad 5f6b2fdc6f fix tests 2025-12-09 16:03:33 -05:00
GitHub Action e34f18991e chore: format code 2025-12-09 20:53:06 +00:00
GitHub Action 209b0a06f7 chore: regen sdk 2025-12-09 20:52:31 +00:00
Dax Raad a2e460bc4b discover logic 2025-12-09 15:51:55 -05:00
GitHub Action fc9081afe4 chore: format code 2025-12-09 20:49:25 +00:00
GitHub Action 1a3f7c3d84 chore: regen sdk 2025-12-09 20:48:49 +00:00
Dax Raad 06aa1f49b8 sync 2025-12-09 15:48:22 -05:00
GitHub Action dfd67cd922 chore: format code 2025-12-09 20:44:09 +00:00
GitHub Action 70f7287ca1 chore: regen sdk 2025-12-09 20:43:35 +00:00
Dax Raad f1955b4d05 core: fix project event emission to include updated worktree data 2025-12-09 15:41:38 -05:00
Dax Raad c5e5627cbd test fixes 2025-12-09 15:41:38 -05:00
GitHub Action 93378526b9 chore: format code 2025-12-09 20:26:02 +00:00
GitHub Action abf176a335 chore: regen sdk 2025-12-09 20:25:21 +00:00
Aiden Cline 84a0868e66 fix: read when file is svg 2025-12-09 14:24:30 -06:00
GitHub Action 75a9c42789 chore: format code 2025-12-09 20:20:03 +00:00
GitHub Action 204fa54625 chore: regen sdk 2025-12-09 20:19:27 +00:00
Dax Raad 365584048f core: fix project creation to include updated timestamp 2025-12-09 15:18:55 -05:00
Dax Raad edffcc32cf core: make project updated timestamp optional to support legacy project data 2025-12-09 15:18:55 -05:00
GitHub Action 238f441bcb chore: format code 2025-12-09 20:17:37 +00:00
GitHub Action 0571a8302c chore: regen sdk 2025-12-09 20:16:59 +00:00
Aiden Cline 8c07382382 ci: fix sdk gen 2025-12-09 14:16:22 -06:00
GitHub Action fa32fbd187 chore: format code 2025-12-09 20:12:07 +00:00
GitHub Action 0fd2ecd0ba chore: regen sdk 2025-12-09 20:11:32 +00:00
Dax Raad 7439a40b00 core: fix project icon update to preserve existing icon properties 2025-12-09 15:11:00 -05:00
GitHub Action 2ad99713f3 chore: format code 2025-12-09 20:07:32 +00:00
GitHub Action 19ec970701 chore: regen sdk 2025-12-09 20:06:57 +00:00
Dax Raad b48caec218 core: add automatic project icon discovery from favicon/logo files 2025-12-09 15:06:24 -05:00
GitHub Action 380c34af53 chore: format code 2025-12-09 19:54:54 +00:00
GitHub Action 553d9013eb chore: regen sdk 2025-12-09 19:54:19 +00:00
Dax Raad 8bff3cdae8 fix ci 2025-12-09 14:53:47 -05:00
Dax Raad 0b40c3d37d rework project loading 2025-12-09 14:41:14 -05:00
Dax Raad 1e3bdcc71c rename bus 2025-12-09 14:32:09 -05:00
GitHub Action de577e17da chore: format code 2025-12-09 19:31:07 +00:00
GitHub Action 8a9e258ad7 chore: regen sdk 2025-12-09 19:30:32 +00:00
Adam 9a34965432 feat: add color to project 2025-12-09 13:29:59 -06:00
Adam c944d19c3b wip(desktop): progress 2025-12-09 13:24:37 -06:00
Dax Raad fb1b6c5e6b add project.name/icon 2025-12-09 13:57:18 -05:00
GitHub Action ad0c4c5d89 chore: format code 2025-12-09 18:37:14 +00:00
GitHub Action a54b663a39 chore: regen sdk 2025-12-09 18:36:38 +00:00
Adam ae4993f39a wip(desktop): progress 2025-12-09 12:36:06 -06:00
GitHub Action aa638cec48 chore: format code 2025-12-09 18:10:31 +00:00
GitHub Action 4db4a90559 chore: regen sdk 2025-12-09 18:07:43 +00:00
Aiden Cline e23a81097c core: add test to prevent MCP headers regression when OAuth is enabled
Custom headers configured for remote MCP servers were being silently
dropped when OAuth was enabled (the default). This test ensures headers
are always sent to MCP servers regardless of OAuth configuration.
2025-12-09 12:06:40 -06:00
1375 changed files with 5470 additions and 11959 deletions
@@ -1,4 +1,4 @@
name: format
name: generate
on:
push:
@@ -8,14 +8,10 @@ on:
branches-ignore:
- production
workflow_dispatch:
workflow_run:
workflows: ["sdk"]
types:
- completed
jobs:
format:
generate:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.event.workflow_run.conclusion == 'success'
permissions:
contents: write
steps:
@@ -29,9 +25,14 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: run
- name: Generate SDK
run: |
./script/format.ts
bun ./packages/sdk/js/script/build.ts
(cd packages/opencode && bun dev generate > ../sdk/openapi.json)
bun x prettier --write packages/sdk/openapi.json
- name: Format
run: ./script/format.ts
env:
CI: true
PUSH_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }}
+1
View File
@@ -29,5 +29,6 @@ jobs:
uses: sst/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_PERMISSION: '{"bash": "deny"}'
with:
model: opencode/claude-haiku-4-5
+19 -19
View File
@@ -2,11 +2,15 @@ name: publish
run-name: "${{ format('release {0}', inputs.bump) }}"
on:
push:
branches:
- dev
- snapshot-*
workflow_dispatch:
inputs:
bump:
description: "Bump major, minor, or patch"
required: true
required: false
type: choice
options:
- major
@@ -20,6 +24,7 @@ on:
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
id-token: write
contents: write
packages: write
@@ -34,20 +39,13 @@ jobs:
- run: git fetch --force --tags
- uses: actions/setup-go@v5
with:
go-version: ">=1.24.0"
cache: true
cache-dependency-path: go.sum
- uses: ./.github/actions/setup-bun
- name: Install makepkg
- name: Setup SSH for AUR
if: inputs.bump || inputs.version
run: |
sudo apt-get update
sudo apt-get install -y pacman-package-manager
- name: Setup SSH for AUR
run: |
mkdir -p ~/.ssh
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
@@ -56,11 +54,8 @@ jobs:
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
- name: Install OpenCode
run: curl -fsSL https://opencode.ai/install | bash
- name: Setup npm auth
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
if: inputs.bump || inputs.version
run: bun i -g opencode-ai@1.0.143
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -69,19 +64,24 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Publish
run: |
./script/publish.ts
env:
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }}
OPENCODE_CHANNEL: latest
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
AUR_KEY: ${{ secrets.AUR_KEY }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: false
publish-tauri:
if: false # inputs.bump || inputs.version
continue-on-error: true
strategy:
fail-fast: false
-39
View File
@@ -1,39 +0,0 @@
name: sdk
on:
push:
branches-ignore:
- production
pull_request:
branches-ignore:
- production
workflow_dispatch:
jobs:
format:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: run
run: |
bun ./packages/sdk/js/script/build.ts
(cd packages/opencode && bun dev generate > ../sdk/openapi.json)
if [ -z "$(git status --porcelain)" ]; then
echo "No changes to commit"
exit 0
fi
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add -A
git commit -m "chore: regen sdk"
git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify
env:
CI: true
-38
View File
@@ -1,38 +0,0 @@
name: snapshot
on:
workflow_dispatch:
push:
branches:
- dev
- test-bedrock
- v0
- otui-diffs
- snapshot-*
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v5
with:
go-version: ">=1.24.0"
cache: true
cache-dependency-path: go.sum
- uses: ./.github/actions/setup-bun
- name: Publish
run: |
./script/publish.ts
env:
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
+2 -2
View File
@@ -2,8 +2,8 @@ name: "sync-zed-extension"
on:
workflow_dispatch:
release:
types: [published]
# release:
# types: [published]
jobs:
zed:
+1
View File
@@ -1,5 +1,6 @@
---
description: git commit and push
model: opencode/glm-4.6
---
commit and push
+1 -16
View File
@@ -7,22 +7,7 @@
"instructions": ["STYLE_GUIDE.md"],
"provider": {
"opencode": {
"options": {
// "baseURL": "http://localhost:8080",
},
},
},
"mcp": {
"exa": {
"type": "remote",
"url": "https://mcp.exa.ai/mcp",
},
"morph": {
"type": "local",
"command": ["bunx", "@morphllm/morphmcp"],
"environment": {
"ENABLED_TOOLS": "warp_grep",
},
"options": {},
},
},
}
+2 -2
View File
@@ -7,7 +7,7 @@
</picture>
</a>
</p>
<p align="center">The AI coding agent built for the terminal.</p>
<p align="center">The open source AI coding agent.</p>
<p align="center">
<a href="https://opencode.ai/discord"><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>
@@ -30,7 +30,7 @@ scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use --pin -g ubi:sst/opencode # Any OS
mise use -g ubi:sst/opencode # Any OS
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
```
+1
View File
@@ -165,3 +165,4 @@
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
+30 -29
View File
@@ -20,7 +20,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -48,7 +48,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -75,7 +75,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -99,7 +99,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -123,7 +123,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -168,7 +168,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -179,6 +179,7 @@
"aws4fetch": "^1.0.20",
"hono": "catalog:",
"hono-openapi": "catalog:",
"js-base64": "3.7.7",
"luxon": "catalog:",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
@@ -196,7 +197,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -212,7 +213,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.138",
"version": "1.0.145",
"bin": {
"opencode": "./bin/opencode",
},
@@ -241,9 +242,9 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.2.8",
"@opentui/core": "0.1.59",
"@opentui/solid": "0.1.59",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.60",
"@opentui/solid": "0.1.60",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -304,7 +305,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -324,7 +325,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.138",
"version": "1.0.145",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -335,7 +336,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -348,7 +349,7 @@
},
"packages/tauri": {
"name": "@opencode-ai/tauri",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@opencode-ai/desktop": "workspace:*",
"@tauri-apps/api": "^2",
@@ -370,7 +371,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -402,7 +403,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"zod": "catalog:",
},
@@ -413,7 +414,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -462,7 +463,7 @@
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.6.0-beta.10",
"@pierre/precision-diffs": "0.6.1",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
@@ -1141,27 +1142,27 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.2.8", "", { "dependencies": { "@openrouter/sdk": "^0.1.8" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-pQT8AzZBKg9f4bkt4doF486ZlhK0XjKkevrLkiqYgfh1Jplovieu28nK4Y+xy3sF18/mxjqh9/2y6jh01qzLrA=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.2", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "@toon-format/toon": "^2.0.0", "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" }, "optionalPeers": ["@toon-format/toon"] }, "sha512-3Th0vmJ9pjnwcPc2H1f59Mb0LFvwaREZAScfOQIpUxAHjZ7ZawVKDP27qgsteZPmMYqccNMy4r4Y3kgUnNcKAg=="],
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.59", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.59", "@opentui/core-darwin-x64": "0.1.59", "@opentui/core-linux-arm64": "0.1.59", "@opentui/core-linux-x64": "0.1.59", "@opentui/core-win32-arm64": "0.1.59", "@opentui/core-win32-x64": "0.1.59", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vOtEvIulvfCOWJy0EfKAPzAMtDTmC+S0boGYrefjLqIp7tp+bbVJuXVh/8bz6GQTPmbQC6MIk6bv/ij3pdUVkA=="],
"@opentui/core": ["@opentui/core@0.1.60", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.60", "@opentui/core-darwin-x64": "0.1.60", "@opentui/core-linux-arm64": "0.1.60", "@opentui/core-linux-x64": "0.1.60", "@opentui/core-win32-arm64": "0.1.60", "@opentui/core-win32-x64": "0.1.60", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-28jphd0AJo48uvEuKXcT9pJhgAu8I2rEJhPt25cc5ipJ2iw/eDk1uoxrbID80MPDqgOEzN21vXmzXwCd6ao+hg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.59", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JQWq7W/wkmTujW/2/Ig0d7S+701rul87LSW5txQ+GM4o6EWchqHrELwo6jcZpczsyOEj4fXxI2O8l4OVYyMa9A=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.60", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N4feqnOBDA4O4yocpat5vOiV06HqJVwJGx8rEZE9DiOtl1i+1cPQ1Lx6+zWdLhbrVBJ0ENhb7Azox8sXkm/+5Q=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.59", "", { "os": "darwin", "cpu": "x64" }, "sha512-GzafWzMP9Lt4AzUwQAk02lxgITgfvvo33OLCN265LtQBO8w23u0eB7Fjs9W+nmtcvzXtB9q6HuA0PvP9a3OioA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.60", "", { "os": "darwin", "cpu": "x64" }, "sha512-+z3q4WaoIs7ANU8+eTFlvnfCjAS81rk81TOdZm4TJ53Ti3/B+yheWtnV/mLpLLhvZDz2VUVxxRmfDrGMnJb4fQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.59", "", { "os": "linux", "cpu": "arm64" }, "sha512-QMMFg3dr2v43g3jICgzNFYQyU4YL3zHw733MVJINC+c882+qiQ8l0utTFoVEx/iRYeBzFvMVrKZ4f6G8fFrtrw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.60", "", { "os": "linux", "cpu": "arm64" }, "sha512-/Q65sjqVGB9ygJ6lStI8n1X6RyfmJZC8XofRGEuFiMLiWcWC/xoBtztdL8LAIvHQy42y2+pl9zIiW0fWSQ0wjw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.59", "", { "os": "linux", "cpu": "x64" }, "sha512-XSblVjhW/7+Xs+/o+xJHwHn74nw9j69mnPAFiNdH0d8ilP4j09nUYHZOvQ89sHZaMYeSIuJEciHnh/qP0n5QXQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.60", "", { "os": "linux", "cpu": "x64" }, "sha512-AegF+g7OguIpjZKN+PS55sc3ZFY6fj+fLwfETbSRGw6NqX+aiwpae0Y3gXX1s298Yq5yQEzMXnARTCJTGH4uzg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.59", "", { "os": "win32", "cpu": "arm64" }, "sha512-GU5pPUcTpYmeOUYKpQgAPx0VKBMrfz5LNZlK8gm/jlo2CbLrIW7QLMWCoxncVZmNYqYJeG+KUZkmXYe5KLPXCQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.60", "", { "os": "win32", "cpu": "arm64" }, "sha512-fbkq8MOZJgT3r9q3JWqsfVxRpQ1SlbmhmvB35BzukXnZBK8eA178wbSadGH6irMDrkSIYye9WYddHI/iXjmgVQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.59", "", { "os": "win32", "cpu": "x64" }, "sha512-InIawEI0TOG8MBBpavMq31WBRBjJ6XPuqFcsDnjqDJcXrRbNkguRW3PNXEwlyaU4tXHfYOsdlPpRtsysS8X/bQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.60", "", { "os": "win32", "cpu": "x64" }, "sha512-OebCL7f9+CKodBw0G+NvKIcc74bl6/sBEHfb73cACdJDJKh+T3C3Vt9H3kQQ0m1C8wRAqX6rh706OArk1pUb2A=="],
"@opentui/solid": ["@opentui/solid@0.1.59", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.59", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-O88a/+YHkHlDC4IxbrfWD2ZWlpkpu4oXC2FCLTK8taaUAnLYoybxdrMpv1+o8u8KoWXOoZmEHdntdO9O4abHnQ=="],
"@opentui/solid": ["@opentui/solid@0.1.60", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.60", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-pn91stzAHNGWaNL6h39q55bq3G1/DLqxKtT3wVsRAV68dHfPpwmqikX1nEJZK8OU84ZTPS9Ly9fz8po2Mot2uQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1277,7 +1278,7 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.6.0-beta.10", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-2rdd1Q1xJbB0Z4oUbm0Ybrr2gLFEdvNetZLadJboZSFL7Q4gFujdQZfXfV3vB9X+esjt++v0nzb3mioW25BOTA=="],
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.6.1", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-HXafRSOly6B0rRt6fuP0yy1MimHJMQ2NNnBGcIHhHwsgK4WWs+SBWRWt1usdgz0NIuSgXdIyQn8HY3F1jKyDBQ=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1764947035,
"narHash": "sha256-EYHSjVM4Ox4lvCXUMiKKs2vETUSL5mx+J2FfutM7T9w=",
"lastModified": 1765270179,
"narHash": "sha256-g2a4MhRKu4ymR4xwo+I+auTknXt/+j37Lnf0Mvfl1rE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a672be65651c80d3f592a89b3945466584a22069",
"rev": "677fbe97984e7af3175b6c121f3c39ee5c8d62c9",
"type": "github"
},
"original": {
+3 -3
View File
@@ -1,10 +1,10 @@
import { SECRET } from "./secret"
import { domain } from "./stage"
import { domain, shortDomain } from "./stage"
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
const enterprise = new sst.cloudflare.x.SolidStart("Enterprise", {
domain: "enterprise." + domain,
const teams = new sst.cloudflare.x.SolidStart("Teams", {
domain: shortDomain,
path: "packages/enterprise",
buildCommand: "bun run build:cloudflare",
environment: {
+6
View File
@@ -11,3 +11,9 @@ new cloudflare.RegionalHostname("RegionalHostname", {
regionKey: "us",
zoneId: zoneID,
})
export const shortDomain = (() => {
if ($app.stage === "production") return "opncd.ai"
if ($app.stage === "dev") return "dev.opncd.ai"
return `${$app.stage}.dev.opncd.ai`
})()
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-lM/7mkrPHz5E6YOMjWspfRhKjwav9ANrLt9kYlpPkEI="
"nodeModules": "sha256-WcFRAG8w1XwgZxzyAawkQ7mYfJxu5VhA3sPApEG6FdI="
}
+1 -1
View File
@@ -30,7 +30,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.6.0-beta.10",
"@pierre/precision-diffs": "0.6.1",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.97",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.138",
"version": "1.0.145",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -13,7 +13,7 @@ export default function App() {
root={(props) => (
<MetaProvider>
<Title>opencode</Title>
<Meta name="description" content="OpenCode - The AI coding agent built for the terminal." />
<Meta name="description" content="OpenCode - The open source coding agent." />
<Favicon />
<Suspense>{props.children}</Suspense>
</MetaProvider>
+4 -4
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/sst/opencode",
starsFormatted: {
compact: "35K",
full: "35,000",
compact: "38K",
full: "38,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "350",
commits: "5,000",
contributors: "375",
commits: "5,250",
monthlyUsers: "400,000",
},
} as const
+5 -12
View File
@@ -157,15 +157,9 @@ export default function Home() {
<section data-component="what">
<div data-slot="section-title">
<h3>What is OpenCode?</h3>
<p>OpenCode is an open source agent that helps you write and run code directly from the terminal.</p>
<p>OpenCode is an open source agent that helps you write code in your terminal, IDE, or desktop.</p>
</div>
<ul>
<li>
<span>[*]</span>
<div>
<strong>Native TUI</strong> A responsive, native, themeable terminal UI
</div>
</li>
<li>
<span>[*]</span>
<div>
@@ -199,7 +193,7 @@ export default function Home() {
<li>
<span>[*]</span>
<div>
<strong>Any editor</strong> OpenCode runs in your terminal, pair it with any IDE
<strong>Any editor</strong> Available as a terminal interface, desktop app, and IDE extension
</div>
</li>
</ul>
@@ -651,9 +645,8 @@ export default function Home() {
<ul>
<li>
<Faq question="What is OpenCode?">
OpenCode is an open source agent that helps you write and run code directly from the terminal. You can
pair OpenCode with any AI model, and because its terminal-based you can pair it with your preferred
code editor.
OpenCode is an open source agent that helps you write and run code with any AI model. It's available
as a terminal-based interface, desktop app, or IDE extension.
</Faq>
</li>
<li>
@@ -674,7 +667,7 @@ export default function Home() {
</li>
<li>
<Faq question="Can I only use OpenCode in the terminal?">
Yes, for now. We are actively working on a desktop app. Join the waitlist for early access.
Not anymore! OpenCode is now available as an app for your desktop.
</Faq>
</li>
<li>
@@ -0,0 +1,20 @@
import type { APIEvent } from "@solidjs/start/server"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const targetUrl = `https://enterprise.opencode.ai/${url.pathname}${url.search}`
const response = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
body: req.body,
})
return response
}
export const GET = handler
export const POST = handler
export const PUT = handler
export const DELETE = handler
export const OPTIONS = handler
export const PATCH = handler
@@ -588,7 +588,7 @@ export async function handler(
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(eq(KeyTable.id, authInfo.apiKeyId)),
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.138",
"version": "1.0.145",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.138",
"version": "1.0.145",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.138",
"version": "1.0.145",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.138",
"version": "1.0.145",
"description": "",
"type": "module",
"exports": {
@@ -8,7 +8,7 @@
"./vite": "./vite.js"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"typecheck": "tsgo -b",
"start": "vite",
"dev": "vite",
"build": "vite build",
+7 -1
View File
@@ -15,8 +15,14 @@ import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session"
import { Show } from "solid-js"
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
}
}
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
const port = window.__OPENCODE__?.port ?? import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
const url =
new URLSearchParams(document.location.search).get("url") ||
+225 -53
View File
@@ -1,9 +1,8 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
import { DateTime } from "luxon"
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
import { useSDK } from "@/context/sdk"
import { useNavigate } from "@solidjs/router"
@@ -15,19 +14,58 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { Tag } from "@opencode-ai/ui/tag"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useLayout } from "@/context/layout"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List, ListRef } from "@opencode-ai/ui/list"
import { iife } from "@opencode-ai/util/iife"
import { Input } from "@opencode-ai/ui/input"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconName } from "@opencode-ai/ui/icons/provider"
interface PromptInputProps {
class?: string
ref?: (el: HTMLDivElement) => void
}
const PLACEHOLDERS = [
"Fix a TODO in the codebase",
"What is the tech stack of this project?",
"Fix broken tests",
"Explain how authentication works",
"Find and fix security vulnerabilities",
"Add unit tests for the user service",
"Refactor this function to be more readable",
"What does this error mean?",
"Help me debug this issue",
"Generate API documentation",
"Optimize database queries",
"Add input validation",
"Create a new component for...",
"How do I deploy this project?",
"Review my code for best practices",
"Add error handling to this function",
"Explain this regex pattern",
"Convert this to TypeScript",
"Add logging throughout the codebase",
"What dependencies are outdated?",
"Help me write a migration script",
"Implement caching for this endpoint",
"Add pagination to this list",
"Create a CLI command for...",
"How do environment variables work here?",
]
export const PromptInput: Component<PromptInputProps> = (props) => {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const local = useLocal()
const session = useSession()
const layout = useLayout()
const providers = useProviders()
let editorRef!: HTMLDivElement
const [store, setStore] = createStore<{
@@ -36,6 +74,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
popoverIsOpen: false,
})
const [placeholder, setPlaceholder] = createSignal(Math.floor(Math.random() * PLACEHOLDERS.length))
onMount(() => {
const interval = setInterval(() => {
setPlaceholder((prev) => (prev + 1) % PLACEHOLDERS.length)
}, 6500)
onCleanup(() => clearInterval(interval))
})
createEffect(() => {
session.id
editorRef.focus()
@@ -68,7 +115,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleFileSelect = (path: string | undefined) => {
if (!path) return
addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
}
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
@@ -403,7 +450,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
<Show when={!session.prompt.dirty()}>
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
Plan and build anything
Ask anything... "{PLACEHOLDERS[placeholder()]}"
</div>
</Show>
</div>
@@ -416,55 +463,180 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="capitalize"
variant="ghost"
/>
<SelectDialog
title="Select model"
placeholder="Search models"
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={local.model.list()}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
sortGroupsBy={(a, b) => {
const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (order.includes(aProvider) && !order.includes(bProvider)) return -1
if (!order.includes(aProvider) && order.includes(bProvider)) return 1
return order.indexOf(aProvider) - order.indexOf(bProvider)
}}
onSelect={(x) =>
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
}
trigger={
<Button as="div" variant="ghost">
{local.model.current()?.name ?? "Select model"}
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
<Icon name="chevron-down" size="small" />
</Button>
}
>
{(i) => (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0" />
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
<Show when={false}>
<span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
{DateTime.fromFormat("unknown", "yyyy-MM-dd").toFormat("LLL yyyy")}
</span>
</Show>
</div>
</div>
<Show when={!i.cost || i.cost?.input === 0}>
<div class="overflow-hidden text-12-medium text-text-strong">Free</div>
</Show>
</div>
)}
</SelectDialog>
<Button as="div" variant="ghost" onClick={() => layout.dialog.open("model")}>
{local.model.current()?.name ?? "Select model"}
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
<Icon name="chevron-down" size="small" />
</Button>
<Show when={layout.dialog.opened() === "model"}>
<Switch>
<Match when={providers().connected().length > 0}>
<SelectDialog
defaultOpen
onOpenChange={(open) => {
if (open) {
layout.dialog.open("model")
} else {
layout.dialog.close("model")
}
}}
title="Select model"
placeholder="Search models"
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={local.model.list()}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
// groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) =>
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
}
actions={
<Button
class="h-7 -my-1 text-14-medium"
icon="plus-small"
tabIndex={-1}
onClick={() => layout.dialog.open("provider")}
>
Connect provider
</Button>
}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Show when={!i.cost || i.cost?.input === 0}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</SelectDialog>
</Match>
<Match when={true}>
{iife(() => {
let listRef: ListRef | undefined
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
return (
<Dialog
modal
defaultOpen
onOpenChange={(open) => {
if (open) {
layout.dialog.open("model")
} else {
layout.dialog.close("model")
}
}}
>
<Dialog.Header>
<Dialog.Title>Select model</Dialog.Title>
<Dialog.CloseButton tabIndex={-1} />
</Dialog.Header>
<Dialog.Body>
<Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} />
<div class="flex flex-col gap-3 px-2.5">
<div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
<List
ref={(ref) => (listRef = ref)}
items={local.model.list()}
current={local.model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
layout.dialog.close("model")
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Tag>Free</Tag>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
<div />
<div />
</div>
<div class="px-1.5 pb-1.5">
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-6">
<div class="px-2 text-14-medium text-text-base">
Add more models from popular providers
</div>
<List
class="w-full"
key={(x) => x?.id}
items={providers().popular()}
activeIcon="plus-small"
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
onSelect={(x) => {
layout.dialog.close("model")
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-4">
<ProviderIcon
data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
"text-icon-weak-base": true,
"size-4 mx-0.5": i.id === "opencode",
"size-5": i.id !== "opencode",
}}
/>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">
Connect with Claude Pro/Max or API key
</div>
</Show>
</div>
)}
</List>
<Button variant="ghost" class="w-full justify-start">
<div class="flex items-center gap-2">
<Icon name="plus-small" />
<div class="text-text-strong">View all providers</div>
</div>
</Button>
</div>
</div>
</div>
</Dialog.Body>
</Dialog>
)
})}
</Match>
</Switch>
</Show>
</div>
<Tooltip
placement="top"
+1 -1
View File
@@ -19,7 +19,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
sdk.global.event().then(async (events) => {
for await (const event of events.stream) {
// console.log("event", event)
emitter.emit(event.directory, event.payload)
emitter.emit(event.directory ?? "global", event.payload)
}
})
+42 -18
View File
@@ -1,7 +1,6 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
@@ -12,6 +11,7 @@ import type {
FileDiff,
Todo,
SessionStatus,
ProviderListResponse,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
@@ -20,9 +20,9 @@ import { useGlobalSDK } from "./global-sdk"
type State = {
ready: boolean
provider: Provider[]
agent: Agent[]
project: Project
project: string
provider: ProviderListResponse
config: Config
path: Path
session: Session[]
@@ -49,27 +49,29 @@ type State = {
export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
name: "GlobalSync",
init: () => {
const sdk = useGlobalSDK()
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
projects: Project[]
project: Project[]
provider: ProviderListResponse
children: Record<string, State>
}>({
ready: false,
projects: [],
project: [],
provider: { all: [], connected: [], default: {} },
children: {},
})
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) {
if (!children[directory]) {
setGlobalStore("children", directory, {
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
project: "",
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
path: { state: "", config: "", worktree: "", directory: "", home: "" },
ready: false,
agent: [],
provider: [],
session: [],
session_status: {},
session_diff: {},
@@ -85,12 +87,31 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
return children[directory]
}
const sdk = useGlobalSDK()
sdk.event.listen((e) => {
const directory = e.name
const [store, setStore] = child(directory)
const event = e.details
if (directory === "global") {
switch (event.type) {
case "project.updated": {
const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
if (result.found) {
setGlobalStore("project", result.index, reconcile(event.properties))
return
}
setGlobalStore(
"project",
produce((draft) => {
draft.splice(result.index, 0, event.properties)
}),
)
break
}
}
return
}
const [store, setStore] = child(directory)
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
@@ -161,14 +182,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
})
Promise.all([
sdk.client.project.list().then((x) =>
sdk.client.project.list().then(async (x) => {
setGlobalStore(
"projects",
"project",
x
.data!.filter((x) => !x.worktree.includes("opencode-test") && x.vcs)
.sort((a, b) => b.time.created - a.time.created),
),
),
.data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs)
.sort((a, b) => a.id.localeCompare(b.id)),
)
}),
sdk.client.provider.list().then((x) => {
setGlobalStore("provider", x.data ?? {})
}),
]).then(() => setGlobalStore("ready", true))
return {
+71 -11
View File
@@ -4,6 +4,20 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { Project } from "@opencode-ai/sdk/v2"
const PASTEL_COLORS = [
"#FCEAFD", // pastel pink
"#FFDFBA", // pastel peach
"#FFFFBA", // pastel yellow
"#BAFFC9", // pastel green
"#EAF6FD", // pastel blue
"#EFEAFD", // pastel lavender
"#FEC8D8", // pastel rose
"#D4F0F0", // pastel cyan
"#FDF0EA", // pastel coral
"#C1E1C1", // pastel mint
]
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
@@ -12,7 +26,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync()
const [store, setStore] = makePersisted(
createStore({
projects: [] as { directory: string; expanded: boolean }[],
projects: [] as { worktree: string; expanded: boolean }[],
sidebar: {
opened: false,
width: 280,
@@ -26,9 +40,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
}),
{
name: "default-layout.v4",
name: "default-layout.v7",
},
)
const [ephemeral, setEphemeral] = createStore({
dialog: {
open: undefined as undefined | "provider" | "model",
},
})
const usedColors = new Set<string>()
function pickAvailableColor() {
const available = PASTEL_COLORS.filter((c) => !usedColors.has(c))
if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)]
return available[Math.floor(Math.random() * available.length)]
}
function enrich(project: { worktree: string; expanded: boolean }) {
const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
if (!metadata) return []
return [
{
...project,
...metadata,
},
]
}
function colorize(project: Project & { expanded: boolean }) {
if (project.icon?.color) return project
const color = pickAvailableColor()
usedColors.add(color)
project.icon = { ...project.icon, color }
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
return project
}
const enriched = createMemo(() => store.projects.flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize))
async function loadProjectSessions(directory: string) {
const [, setStore] = globalSync.child(directory)
@@ -43,32 +92,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
onMount(() => {
Promise.all(
store.projects.map(({ directory }) => {
return loadProjectSessions(directory)
store.projects.map((project) => {
return loadProjectSessions(project.worktree)
}),
)
})
return {
projects: {
list: createMemo(() => store.projects),
list,
open(directory: string) {
if (store.projects.find((x) => x.directory === directory)) return
if (store.projects.find((x) => x.worktree === directory)) return
loadProjectSessions(directory)
setStore("projects", (x) => [...x, { directory, expanded: true }])
setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
},
close(directory: string) {
setStore("projects", (x) => x.filter((x) => x.directory !== directory))
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
},
expand(directory: string) {
setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: true } : x)))
setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: true } : x)))
},
collapse(directory: string) {
setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: false } : x)))
setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: false } : x)))
},
move(directory: string, toIndex: number) {
setStore("projects", (projects) => {
const fromIndex = projects.findIndex((x) => x.directory === directory)
const fromIndex = projects.findIndex((x) => x.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return projects
const result = [...projects]
const [item] = result.splice(fromIndex, 1)
@@ -118,6 +167,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "state", "tab")
},
},
dialog: {
opened: createMemo(() => ephemeral.dialog?.open),
open(dialog: "provider" | "model") {
setEphemeral("dialog", "open", dialog)
},
close(dialog: "provider" | "model") {
if (ephemeral.dialog?.open === dialog) {
setEphemeral("dialog", "open", undefined)
}
},
},
}
},
})
+23 -8
View File
@@ -25,6 +25,7 @@ export type View = LocalFile["view"]
export type LocalModel = Omit<Model, "provider"> & {
provider: Provider
latest?: boolean
}
export type ModelKey = { providerID: string; modelID: string }
@@ -38,8 +39,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sync = useSync()
function isModelValid(model: ModelKey) {
const provider = sync.data.provider.find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID]
const provider = sync.data.provider?.all.find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID] && sync.data.provider?.connected.includes(model.providerID)
}
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
@@ -114,7 +115,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const list = createMemo(() =>
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
sync.data.provider.all
.filter((p) => sync.data.provider.connected.includes(p.id))
.flatMap((p) =>
Object.values(p.models).map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
provider: p,
latest: m.name.includes("(latest)"),
})),
),
)
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
@@ -134,12 +144,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return item
}
}
const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0]
return {
providerID: provider.id,
modelID: model.id,
for (const p of sync.data.provider.connected) {
if (p in sync.data.provider.default) {
return {
providerID: p,
modelID: sync.data.provider.default[p],
}
}
}
throw new Error("No default model found")
})
const currentModel = createMemo(() => {
+2 -5
View File
@@ -7,7 +7,6 @@ import { TextSelection } from "./local"
import { pipe, sumBy } from "remeda"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/util/encode"
import { useSDK } from "./sdk"
export type LocalPTY = {
@@ -25,9 +24,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
const sdk = useSDK()
const params = useParams()
const sync = useSync()
const name = createMemo(
() => `${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}.v2`,
)
const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`)
const [store, setStore] = makePersisted(
createStore<{
@@ -97,7 +94,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
)
const model = createMemo(() =>
last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
)
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+7 -2
View File
@@ -13,8 +13,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const [store, setStore] = globalSync.child(sdk.directory)
const load = {
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!.id)),
provider: () => sdk.client.provider.list().then((x) => setStore("provider", x.data!)),
path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
session: () =>
@@ -41,6 +41,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.ready
},
get project() {
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -0,0 +1,31 @@
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
export function useProviders() {
const params = useParams()
const globalSync = useGlobalSync()
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const providers = createMemo(() => {
if (currentDirectory()) {
const [projectStore] = globalSync.child(currentDirectory())
return projectStore.provider
}
return globalSync.data.provider
})
const connected = createMemo(() =>
providers().all.filter(
(p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input),
),
)
const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id)))
return createMemo(() => ({
all: providers().all,
default: providers().default,
popular,
connected,
}))
}
+12 -5
View File
@@ -7,6 +7,7 @@ import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/util/encode"
import { Icon } from "@opencode-ai/ui/icon"
import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
export default function Home() {
const sync = useGlobalSync()
@@ -37,7 +38,7 @@ export default function Home() {
<div class="mx-auto mt-55">
<Logo class="w-xl opacity-12" />
<Switch>
<Match when={sync.data.projects.length > 0}>
<Match when={sync.data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">Recent projects</div>
@@ -47,8 +48,12 @@ export default function Home() {
</Button>
</Show>
</div>
<ol class="flex flex-col gap-2">
<For each={sync.data.projects.slice(0, 5)}>
<ul class="flex flex-col gap-2">
<For
each={sync.data.project
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)}
>
{(project) => (
<Button
size="large"
@@ -57,11 +62,13 @@ export default function Home() {
onClick={() => openProject(project.worktree)}
>
{project.worktree}
<div class="text-14-regular text-text-weak">10m ago</div>
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
</Button>
)}
</For>
</ol>
</ul>
</div>
</Match>
<Match when={true}>
+174 -39
View File
@@ -9,6 +9,7 @@ import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
@@ -16,7 +17,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
import { Select } from "@opencode-ai/ui/select"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client"
import { Session, Project } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { createStore } from "solid-js/store"
import {
@@ -29,6 +30,10 @@ import {
useDragDropContext,
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import { Tag } from "@opencode-ai/ui/tag"
import { IconName } from "@opencode-ai/ui/icons/provider"
import { popularProviders, useProviders } from "@/hooks/use-providers"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -44,6 +49,7 @@ export default function Layout(props: ParentProps) {
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const providers = useProviders()
function navigateToProject(directory: string | undefined) {
if (!directory) return
@@ -82,12 +88,21 @@ export default function Layout(props: ParentProps) {
}
}
async function connectProvider() {
layout.dialog.open("provider")
}
createEffect(() => {
if (!params.dir || !params.id) return
const directory = base64Decode(params.dir)
setStore("lastSession", directory, params.id)
})
createEffect(() => {
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
})
function getDraggableId(event: unknown): string | undefined {
if (typeof event !== "object" || event === null) return undefined
if (!("draggable" in event)) return undefined
@@ -106,8 +121,8 @@ export default function Layout(props: ParentProps) {
const { draggable, droppable } = event
if (draggable && droppable) {
const projects = layout.projects.list()
const fromIndex = projects.findIndex((p) => p.directory === draggable.id.toString())
const toIndex = projects.findIndex((p) => p.directory === droppable.id.toString())
const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== -1) {
layout.projects.move(draggable.id.toString(), toIndex)
}
@@ -140,27 +155,74 @@ export default function Layout(props: ParentProps) {
return <></>
}
const SortableProject = (props: { project: { directory: string; expanded: boolean } }): JSX.Element => {
const sortable = createSortable(props.project.directory)
const [projectStore] = globalSync.child(props.project.directory)
const slug = createMemo(() => base64Encode(props.project.directory))
const name = createMemo(() => getFilename(props.project.directory))
const ProjectVisual = (props: { project: Project & { expanded: boolean }; class?: string }): JSX.Element => {
const name = createMemo(() => getFilename(props.project.worktree))
return (
<Switch>
<Match when={layout.sidebar.opened()}>
<Button
as={"div"}
variant="ghost"
data-active
class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg"
>
<div class="flex items-center gap-3 p-0 text-left min-w-0 grow">
<div class="size-6 shrink-0">
<Avatar
fallback={name()}
src={props.project.icon?.url}
background={props.project.icon?.color ?? "var(--surface-info-base)"}
class="size-full"
/>
</div>
<span class="truncate text-14-medium text-text-strong">{name()}</span>
</div>
</Button>
</Match>
<Match when={true}>
<Button
variant="ghost"
size="large"
class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
data-selected={props.project.worktree === currentDirectory()}
onClick={() => navigateToProject(props.project.worktree)}
>
<div class="size-6 shrink-0">
<Avatar
fallback={name()}
src={props.project.icon?.url}
background={props.project.icon?.color ?? "var(--surface-info-base)"}
class="size-full"
/>
</div>
</Button>
</Match>
</Switch>
)
}
const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
const sortable = createSortable(props.project.worktree)
const [projectStore] = globalSync.child(props.project.worktree)
const slug = createMemo(() => base64Encode(props.project.worktree))
const name = createMemo(() => getFilename(props.project.worktree))
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Switch>
<Match when={layout.sidebar.opened()}>
<Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0">
<Button
as={"div"}
variant="ghost"
class="group/session flex items-center justify-between gap-3 w-full px-1 self-stretch h-auto border-none"
class="group/session flex items-center justify-between gap-3 w-full px-1 self-stretch h-auto border-none rounded-lg"
>
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
<div class="size-6 shrink-0">
<Avatar
fallback={name()}
background="var(--surface-info-base)"
src={props.project.icon?.url}
background={props.project.icon?.color ?? "var(--surface-info-base)"}
class="size-full group-hover/session:hidden"
/>
<Icon
@@ -176,7 +238,7 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => closeProject(props.project.directory)}>
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
<DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
@@ -238,18 +300,8 @@ export default function Layout(props: ParentProps) {
</Collapsible>
</Match>
<Match when={true}>
<Tooltip placement="right" value={props.project.directory}>
<Button
variant="ghost"
size="large"
class="flex items-center justify-center p-0 aspect-square border-none"
data-selected={props.project.directory === currentDirectory()}
onClick={() => navigateToProject(props.project.directory)}
>
<div class="size-6 shrink-0 inset-0">
<Avatar fallback={name()} background="var(--surface-info-base)" class="size-full" />
</div>
</Button>
<Tooltip placement="right" value={props.project.worktree}>
<ProjectVisual project={props.project} />
</Tooltip>
</Match>
</Switch>
@@ -258,16 +310,12 @@ export default function Layout(props: ParentProps) {
}
const ProjectDragOverlay = (): JSX.Element => {
const activeName = createMemo(() => {
if (!store.activeDraggable) return undefined
return getFilename(store.activeDraggable)
})
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable))
return (
<Show when={activeName()}>
{(name) => (
<div class="flex items-center gap-3 px-2 py-1 bg-background-stronger rounded-md border border-border-weak-base">
<Avatar fallback={name()} background="var(--surface-info-base)" class="size-6" />
<span class="text-14-medium text-text-strong">{name()}</span>
<Show when={project()}>
{(p) => (
<div class="bg-background-base rounded-md">
<ProjectVisual project={p()} />
</div>
)}
</Show>
@@ -294,7 +342,7 @@ export default function Layout(props: ParentProps) {
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.directory)}
options={layout.projects.list().map((project) => project.worktree)}
current={currentDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
@@ -386,7 +434,7 @@ export default function Layout(props: ParentProps) {
<Button
variant="ghost"
size="large"
class="group/sidebar-toggle shrink-0 w-full text-left justify-start"
class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg"
onClick={layout.sidebar.toggle}
>
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
@@ -422,7 +470,7 @@ export default function Layout(props: ParentProps) {
<DragDropSensors />
<ConstrainDragXAxis />
<div class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar">
<SortableProvider ids={layout.projects.list().map((p) => p.directory)}>
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
<For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For>
</SortableProvider>
</div>
@@ -432,10 +480,44 @@ export default function Layout(props: ParentProps) {
</DragDropProvider>
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch>
<Match when={!providers().connected().length && layout.sidebar.opened()}>
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div>
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
</div>
<Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
size="large"
icon="plus-small"
onClick={connectProvider}
>
<Show when={layout.sidebar.opened()}>Connect provider</Show>
</Button>
</Tooltip>
</div>
</Match>
<Match when={true}>
<Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="plus-small"
onClick={connectProvider}
>
<Show when={layout.sidebar.opened()}>Connect provider</Show>
</Button>
</Tooltip>
</Match>
</Switch>
<Show when={platform.openDirectoryPickerDialog}>
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="folder-add-left"
@@ -448,7 +530,7 @@ export default function Layout(props: ParentProps) {
<Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}>
<Button
disabled
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="settings-gear"
@@ -461,7 +543,7 @@ export default function Layout(props: ParentProps) {
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="bubble-5"
@@ -472,6 +554,59 @@ export default function Layout(props: ParentProps) {
</div>
</div>
<main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
<Show when={layout.dialog.opened() === "provider"}>
<SelectDialog
defaultOpen
title="Connect provider"
placeholder="Search providers"
activeIcon="plus-small"
key={(x) => x?.id}
items={providers().all}
// current={local.model.current()}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
sortGroupsBy={(a, b) => {
if (a.category === "Popular" && b.category !== "Popular") return -1
if (b.category === "Popular" && a.category !== "Popular") return 1
return 0
}}
// onSelect={(x) => }
onOpenChange={(open) => {
if (open) {
layout.dialog.open("provider")
} else {
layout.dialog.close("provider")
}
}}
>
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-4">
<ProviderIcon
data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
"text-icon-weak-base": true,
"size-4 mx-0.5": i.id === "opencode",
"size-5": i.id !== "opencode",
}}
/>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
</Show>
</div>
)}
</SelectDialog>
</Show>
</div>
</div>
)
+102 -40
View File
@@ -28,7 +28,7 @@ import {
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
import { useSession } from "@/context/session"
import { useSession, type LocalPTY } from "@/context/session"
import { useLayout } from "@/context/layout"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Terminal } from "@/components/terminal"
@@ -43,6 +43,7 @@ export default function Page() {
clickTimer: undefined as number | undefined,
fileSelectOpen: false,
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
})
let inputRef!: HTMLDivElement
@@ -178,6 +179,49 @@ export default function Page() {
setStore("activeDraggable", undefined)
}
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeTerminalDraggable", id)
}
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const terminals = session.terminal.all()
const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
session.terminal.move(draggable.id.toString(), toIndex)
}
}
}
const handleTerminalDragEnd = () => {
setStore("activeTerminalDraggable", undefined)
}
const SortableTerminalTab = (props: { terminal: LocalPTY }): JSX.Element => {
const sortable = createSortable(props.terminal.id)
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
value={props.terminal.id}
closeButton={
session.terminal.all().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(props.terminal.id)} />
)
}
>
{props.terminal.title}
</Tabs.Trigger>
</div>
</div>
)
}
const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
return (
<div class="flex items-center gap-x-1.5">
@@ -401,15 +445,19 @@ export default function Page() {
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
</div>
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(sync.data.project.time.created).toRelative()}
</span>
</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
</Match>
</Switch>
@@ -614,40 +662,54 @@ export default function Page() {
onResize={layout.terminal.resize}
onCollapse={layout.terminal.close}
/>
<Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}>
<Tabs.List class="h-10">
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}>
<Tabs.List class="h-10">
<SortableProvider ids={session.terminal.all().map((t) => t.id)}>
<For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<Tooltip value="Open file" class="flex items-center">
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
</Tooltip>
</div>
</Tabs.List>
<For each={session.terminal.all()}>
{(terminal) => (
<Tabs.Trigger
value={terminal.id}
closeButton={
session.terminal.all().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(terminal.id)} />
)
}
>
{terminal.title}
</Tabs.Trigger>
<Tabs.Content value={terminal.id}>
<Terminal
pty={terminal}
onCleanup={session.terminal.update}
onConnectError={() => session.terminal.clone(terminal.id)}
/>
</Tabs.Content>
)}
</For>
<div class="h-full flex items-center justify-center">
<Tooltip value="Open file" class="flex items-center">
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
</Tooltip>
</div>
</Tabs.List>
<For each={session.terminal.all()}>
{(terminal) => (
<Tabs.Content value={terminal.id}>
<Terminal
pty={terminal}
onCleanup={session.terminal.update}
onConnectError={() => session.terminal.clone(terminal.id)}
/>
</Tabs.Content>
)}
</For>
</Tabs>
</Tabs>
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {
const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId()))
return (
<Show when={terminal()}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{t().title}
</div>
)}
</Show>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
</Show>
</div>
+1 -1
View File
@@ -20,5 +20,5 @@
"@/*": ["./src/*"]
}
},
"exclude": ["dist"]
"exclude": ["dist", "ts-dist"]
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.138",
"version": "1.0.145",
"private": true,
"type": "module",
"scripts": {
@@ -20,6 +20,7 @@
"@solidjs/meta": "catalog:",
"hono": "catalog:",
"hono-openapi": "catalog:",
"js-base64": "3.7.7",
"luxon": "catalog:",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
+1 -1
View File
@@ -1,4 +1,4 @@
import { FileDiff, Message, Model, Part, Session, SessionStatus } from "@opencode-ai/sdk"
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
import { fn } from "@opencode-ai/util/fn"
import { iife } from "@opencode-ai/util/iife"
import { Identifier } from "@opencode-ai/util/identifier"
-2
View File
@@ -11,8 +11,6 @@ export default createHandler(() => (
<title>OpenCode</title>
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
{assets}
</head>
<body class="antialiased overscroll-none select-none text-12-regular">
+3
View File
@@ -0,0 +1,3 @@
export default function () {
return <div>Hello World</div>
}
+252 -216
View File
@@ -1,4 +1,4 @@
import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk"
import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context"
@@ -8,6 +8,7 @@ import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } fro
import { Share } from "~/core/share"
import { Logo, Mark } from "@opencode-ai/ui/logo"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { createDefaultOptions } from "@opencode-ai/ui/pierre"
import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary"
@@ -21,6 +22,9 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
import { clientOnly } from "@solidjs/start"
import { type IconName } from "@opencode-ai/ui/icons/provider"
import { Meta } from "@solidjs/meta"
import { Base64 } from "js-base64"
const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
@@ -39,6 +43,7 @@ const getData = query(async (shareID) => {
const data = await Share.data(shareID)
const result: {
sessionID: string
shareID: string
session: Session[]
session_diff: {
[sessionID: string]: FileDiff[]
@@ -63,6 +68,7 @@ const getData = query(async (shareID) => {
}
} = {
sessionID: share.sessionID,
shareID,
session: [],
session_diff: {
[share.sessionID]: [],
@@ -151,247 +157,277 @@ export default function () {
)
}}
>
<Meta name="robots" content="noindex, nofollow" />
<Show when={data()}>
{(data) => {
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
const info = createMemo(() => data().session[match().index])
const ogImage = createMemo(() => {
const models = new Set<string>()
const messages = data().message[data().sessionID] ?? []
for (const msg of messages) {
if (msg.role === "assistant" && msg.modelID) {
models.add(msg.modelID)
}
}
const modelIDs = Array.from(models)
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(info().title.substring(0, 700))))
let modelParam: string
if (modelIDs.length === 1) {
modelParam = modelIDs[0]
} else if (modelIDs.length === 2) {
modelParam = encodeURIComponent(`${modelIDs[0]} & ${modelIDs[1]}`)
} else if (modelIDs.length > 2) {
modelParam = encodeURIComponent(`${modelIDs[0]} & ${modelIDs.length - 1} others`)
} else {
modelParam = "unknown"
}
const version = `v${info().version}`
return `https://social-cards.sst.dev/opencode-share/${encodedTitle}.png?model=${modelParam}&version=${version}&id=${data().shareID}`
})
return (
<DiffComponentProvider component={ClientOnlyDiff}>
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => b.time.created - a.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
<>
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
<Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} />
<DiffComponentProvider component={ClientOnlyDiff}>
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => b.time.created - a.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
}
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const title = () => (
<div class="flex flex-col gap-4">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
const title = () => (
<div class="flex flex-col gap-4">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<ProviderIcon id={provider() as IconName} class="size-3.5 shrink-0 text-icon-strong-base" />
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
<div class="flex gap-2 items-center">
<img
src={`https://models.dev/logos/${provider()}.svg`}
class="size-3.5 shrink-0 dark:invert"
/>
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
const turns = () => (
<div class="relative mt-2 pt-6 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container: "px-4",
}}
/>
)}
</For>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
)
const turns = () => (
<div class="relative mt-2 pt-6 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container: "px-4",
}}
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
)}
</For>
</div>
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
)
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div
classList={{ "hidden w-full flex-1 min-h-0": true, "md:flex": wide(), "lg:flex": !wide() }}
>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"mx-auto max-w-146": !wide(),
}}
classList={{ "hidden w-full flex-1 min-h-0": true, "md:flex": wide(), "lg:flex": !wide() }}
>
<div
classList={{
"w-full flex justify-start items-start min-w-0": true,
"max-w-146 mx-auto px-6": wide(),
"pr-6 pl-18": !wide() && messages().length > 1,
"px-6": !wide() && messages().length === 1,
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"mx-auto max-w-146": !wide(),
}}
>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between items-start",
container:
"w-full pb-20 " +
(wide() ? "max-w-146 mx-auto px-6" : messages().length > 1 ? "pr-6 pl-18" : "px-6"),
<div
classList={{
"w-full flex justify-start items-start min-w-0": true,
"max-w-146 mx-auto px-6": wide(),
"pr-6 pl-18": !wide() && messages().length > 1,
"px-6": !wide() && messages().length === 1,
}}
>
<div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
</div>
<Show when={diffs().length > 0}>
<DiffComponentProvider component={SSRDiff}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between items-start",
container:
"w-full pb-20 " +
(wide()
? "max-w-146 mx-auto px-6"
: messages().length > 1
? "pr-6 pl-18"
: "px-6"),
}}
>
<div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
</DiffComponentProvider>
</Show>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<DiffComponentProvider component={SSRDiff}>
<SessionReview
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
}}
/>
</DiffComponentProvider>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}>
{turns()}
</div>
</Match>
</Switch>
<Show when={diffs().length > 0}>
<DiffComponentProvider component={SSRDiff}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
</div>
</DiffComponentProvider>
</Show>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<DiffComponentProvider component={SSRDiff}>
<SessionReview
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
}}
/>
</DiffComponentProvider>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}>
{turns()}
</div>
</Match>
</Switch>
</div>
</div>
</div>
)
})}
</DataProvider>
</DiffComponentProvider>
)
})}
</DataProvider>
</DiffComponentProvider>
</>
)
}}
</Show>
+8 -1
View File
@@ -18,7 +18,14 @@ const nitroConfig: any = (() => {
})()
export default defineConfig({
plugins: [tailwindcss(), solidStart() as PluginOption, nitro(nitroConfig)],
plugins: [
tailwindcss(),
solidStart() as PluginOption,
nitro({
...nitroConfig,
baseURL: process.env.OPENCODE_BASE_URL,
}),
],
server: {
host: "0.0.0.0",
allowedHosts: true,
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.138"
version = "1.0.145"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.138/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.145/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.138/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.145/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.138/opencode-linux-arm64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.145/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.138/opencode-linux-x64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.145/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.138/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.145/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.138",
"version": "1.0.145",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+10 -5
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.138",
"version": "1.0.145",
"name": "opencode",
"type": "module",
"private": true,
@@ -9,7 +9,12 @@
"test": "bun test",
"build": "./script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)'"
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
"lint": "echo 'Running lint checks...' && bun test --coverage",
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'"
},
"bin": {
"opencode": "./bin/opencode"
@@ -65,9 +70,9 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.2.8",
"@opentui/core": "0.1.59",
"@opentui/solid": "0.1.59",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.60",
"@opentui/solid": "0.1.60",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
+13 -18
View File
@@ -35,26 +35,21 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
2,
),
)
for (const [name] of Object.entries(binaries)) {
try {
process.chdir(`./dist/${name}`)
if (process.platform !== "win32") {
await $`chmod 755 -R .`
}
await $`bun publish --access public --tag ${Script.channel}`
} finally {
process.chdir(dir)
}
}
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${Script.channel}`
if (!Script.preview) {
const major = Script.version.split(".")[0]
const majorTag = `latest-${major}`
for (const [name] of Object.entries(binaries)) {
await $`cd dist/${name} && npm dist-tag add ${name}@${Script.version} ${majorTag}`
const tags = [Script.channel]
const tasks = Object.entries(binaries).map(async ([name]) => {
if (process.platform !== "win32") {
await $`chmod 755 -R .`.cwd(`./dist/${name}`)
}
await $`cd ./dist/${pkg.name} && npm dist-tag add ${pkg.name}-ai@${Script.version} ${majorTag}`
await $`bun pm pack`.cwd(`./dist/${name}`)
for (const tag of tags) {
await $`npm publish *.tgz --access public --tag ${tag}`.cwd(`./dist/${name}`)
}
})
await Promise.all(tasks)
for (const tag of tags) {
await $`cd ./dist/${pkg.name} && bun pm pack && npm publish *.tgz --access public --tag ${tag}`
}
if (!Script.preview) {
-1
View File
@@ -25,7 +25,6 @@ import { Provider } from "../provider/provider"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config"
import { MCP } from "@/mcp"
import { Todo } from "@/session/todo"
import { z } from "zod"
import { LoadAPIKeyError } from "ai"
+1 -1
View File
@@ -252,7 +252,7 @@ export namespace Agent {
},
},
temperature: 0.3,
prompt: [
messages: [
...system.map(
(item): ModelMessage => ({
role: "system",
+12 -1
View File
@@ -127,7 +127,18 @@ export namespace BunProc {
await runInstall()
parsed.dependencies[pkg] = version
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkgJson = Bun.file(path.join(mod, "package.json"))
const installedPkg = await installedPkgJson.json().catch(() => null)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
return mod
}
+43
View File
@@ -0,0 +1,43 @@
import z from "zod"
import type { ZodType } from "zod"
import { Log } from "../util/log"
export namespace BusEvent {
const log = Log.create({ service: "event" })
export type Definition = ReturnType<typeof define>
const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
}
registry.set(type, result)
return result
}
export function payloads() {
return z
.discriminatedUnion(
"type",
registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
})
.toArray() as any,
)
.meta({
ref: "Event",
})
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { EventEmitter } from "events"
export const GlobalBus = new EventEmitter<{
event: [
{
directory: string
directory?: string
payload: any
},
]
+11 -48
View File
@@ -1,17 +1,19 @@
import z from "zod"
import type { ZodType } from "zod"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
export namespace Bus {
const log = Log.create({ service: "bus" })
type Subscription = (event: any) => void
const disposedEventType = "server.instance.disposed"
export type EventDefinition = ReturnType<typeof event>
const registry = new Map<string, EventDefinition>()
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
const state = Instance.state(
() => {
@@ -25,7 +27,7 @@ export namespace Bus {
const wildcard = entry.subscriptions.get("*")
if (!wildcard) return
const event = {
type: disposedEventType,
type: InstanceDisposed.type,
properties: {
directory: Instance.directory,
},
@@ -36,46 +38,7 @@ export namespace Bus {
},
)
export function event<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
}
registry.set(type, result)
return result
}
export const InstanceDisposed = event(
disposedEventType,
z.object({
directory: z.string(),
}),
)
export function payloads() {
return z
.discriminatedUnion(
"type",
registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
})
.toArray() as any,
)
.meta({
ref: "Event",
})
}
export async function publish<Definition extends EventDefinition>(
export async function publish<Definition extends BusEvent.Definition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
@@ -100,14 +63,14 @@ export namespace Bus {
return Promise.all(pending)
}
export function subscribe<Definition extends EventDefinition>(
export function subscribe<Definition extends BusEvent.Definition>(
def: Definition,
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
) {
return raw(def.type, callback)
}
export function once<Definition extends EventDefinition>(
export function once<Definition extends BusEvent.Definition>(
def: Definition,
callback: (event: {
type: Definition["type"]
+170 -86
View File
@@ -4,131 +4,215 @@ import { UI } from "../ui"
import { Global } from "../../global"
import { Agent } from "../../agent/agent"
import path from "path"
import fs from "fs/promises"
import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { EOL } from "os"
import type { Argv } from "yargs"
type AgentMode = "all" | "primary" | "subagent"
const AVAILABLE_TOOLS = [
"bash",
"read",
"write",
"edit",
"list",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"todoread",
]
const AgentCreateCommand = cmd({
command: "create",
describe: "create a new agent",
async handler() {
builder: (yargs: Argv) =>
yargs
.option("path", {
type: "string",
describe: "directory path to generate the agent file",
})
.option("description", {
type: "string",
describe: "what the agent should do",
})
.option("mode", {
type: "string",
describe: "agent mode",
choices: ["all", "primary", "subagent"] as const,
})
.option("tools", {
type: "string",
describe: `comma-separated list of tools to enable (default: all). Available: "${AVAILABLE_TOOLS.join(", ")}"`,
}),
async handler(args) {
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Create agent")
const project = Instance.project
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const cliTools = args.tools
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: Instance.worktree,
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
const isFullyNonInteractive = cliPath && cliDescription && cliMode && cliTools !== undefined
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
const project = Instance.project
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: Instance.worktree,
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
targetPath = path.join(
scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
"agent",
)
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const generated = await Agent.generate({ description: query }).catch((error) => {
const generated = await Agent.generate({ description }).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
const availableTools = [
"bash",
"read",
"write",
"edit",
"list",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"todoread",
]
// Select tools
let selectedTools: string[]
if (cliTools !== undefined) {
selectedTools = cliTools ? cliTools.split(",").map((t) => t.trim()) : AVAILABLE_TOOLS
} else {
const result = await prompts.multiselect({
message: "Select tools to enable",
options: AVAILABLE_TOOLS.map((tool) => ({
label: tool,
value: tool,
})),
initialValues: AVAILABLE_TOOLS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selectedTools = result
}
const selectedTools = await prompts.multiselect({
message: "Select tools to enable",
options: availableTools.map((tool) => ({
label: tool,
value: tool,
})),
initialValues: availableTools,
})
if (prompts.isCancel(selectedTools)) throw new UI.CancelledError()
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all",
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
}
// Build tools config
const tools: Record<string, boolean> = {}
for (const tool of availableTools) {
for (const tool of AVAILABLE_TOOLS) {
if (!selectedTools.includes(tool)) {
tools[tool] = false
}
}
const frontmatter: any = {
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
tools?: Record<string, boolean>
} = {
description: generated.whenToUse,
mode: modeResult,
mode,
}
if (Object.keys(tools).length > 0) {
frontmatter.tools = tools
}
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(
scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
`agent`,
`${generated.identifier}.md`,
)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
await fs.mkdir(targetPath, { recursive: true })
const file = Bun.file(filePath)
if (await file.exists()) {
if (isFullyNonInteractive) {
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
}
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Bun.write(filePath, content)
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
if (isFullyNonInteractive) {
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
}
},
})
},
+158 -136
View File
@@ -10,6 +10,154 @@ import { Config } from "../../config/config"
import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
type PluginAuth = NonNullable<Hooks["auth"]>
/**
* Handle plugin-based authentication flow.
* Returns true if auth was handled, false if it should fall through to default handling.
*/
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise<boolean> {
let index = 0
if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
],
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await new Promise((resolve) => setTimeout(resolve, 10))
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
if (prompt.condition && !prompt.condition(inputs)) {
continue
}
if (prompt.type === "select") {
const value = await prompts.select({
message: prompt.message,
options: prompt.options,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
} else {
const value = await prompts.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
}
}
}
if (method.type === "oauth") {
const authorize = await method.authorize(inputs)
if (authorize.url) {
prompts.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
prompts.log.success("Login successful")
}
}
prompts.outro("Done")
return true
}
if (method.type === "api") {
if (method.authorize) {
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
}
return false
}
export const AuthCommand = cmd({
command: "auth",
@@ -160,142 +308,8 @@ export const AuthLoginCommand = cmd({
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
let index = 0
if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
],
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await new Promise((resolve) => setTimeout(resolve, 10))
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
if (prompt.condition && !prompt.condition(inputs)) {
continue
}
if (prompt.type === "select") {
const value = await prompts.select({
message: prompt.message,
options: prompt.options,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
} else {
const value = await prompts.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
}
}
}
if (method.type === "oauth") {
const authorize = await method.authorize(inputs)
if (authorize.url) {
prompts.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
prompts.log.success("Login successful")
}
}
prompts.outro("Done")
return
}
if (method.type === "api") {
if (method.authorize) {
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return
}
}
const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
if (handled) return
}
if (provider === "other") {
@@ -306,6 +320,14 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
// Check if a plugin provides auth for this custom provider
const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
if (handled) return
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
+44 -23
View File
@@ -403,12 +403,12 @@ export const GithubRunCommand = cmd({
let appToken: string
let octoRest: Octokit
let octoGraph: typeof graphql
let commentId: number
let gitConfig: string
let session: { id: string; title: string; version: string }
let shareId: string | undefined
let exitCode = 0
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
const triggerCommentId = payload.comment.id
try {
const actionToken = isMock ? args.token! : await getOidcToken()
@@ -422,8 +422,7 @@ export const GithubRunCommand = cmd({
await configureGit(appToken)
await assertPermissions()
const comment = await createComment()
commentId = comment.data.id
await addReaction("eyes")
// Setup opencode session
const repoData = await fetchRepo()
@@ -455,7 +454,8 @@ export const GithubRunCommand = cmd({
await pushToLocalBranch(summary, uncommittedChanges)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
await updateComment(`${response}${footer({ image: !hasShared })}`)
await createComment(`${response}${footer({ image: !hasShared })}`)
await removeReaction()
}
// Fork PR
else {
@@ -469,7 +469,8 @@ export const GithubRunCommand = cmd({
await pushToForkBranch(summary, prData, uncommittedChanges)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
await updateComment(`${response}${footer({ image: !hasShared })}`)
await createComment(`${response}${footer({ image: !hasShared })}`)
await removeReaction()
}
}
// Issue
@@ -489,9 +490,11 @@ export const GithubRunCommand = cmd({
summary,
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
)
await updateComment(`Created PR #${pr}${footer({ image: true })}`)
await createComment(`Created PR #${pr}${footer({ image: true })}`)
await removeReaction()
} else {
await updateComment(`${response}${footer({ image: true })}`)
await createComment(`${response}${footer({ image: true })}`)
await removeReaction()
}
}
} catch (e: any) {
@@ -503,7 +506,8 @@ export const GithubRunCommand = cmd({
} else if (e instanceof Error) {
msg = e.message
}
await updateComment(`${msg}${footer()}`)
await createComment(`${msg}${footer()}`)
await removeReaction()
core.setFailed(msg)
// Also output the clean error message for the action to capture
//core.setOutput("prepare_error", e.message);
@@ -931,24 +935,41 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
}
async function createComment() {
async function addReaction(reaction: "eyes") {
console.log("Adding reaction...")
return await octoRest.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: triggerCommentId,
content: reaction,
})
}
async function removeReaction() {
console.log("Removing reaction...")
const reactions = await octoRest.rest.reactions.listForIssueComment({
owner,
repo,
comment_id: triggerCommentId,
})
const eyesReaction = reactions.data.find((r) => r.content === "eyes")
if (!eyesReaction) return
await octoRest.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: triggerCommentId,
reaction_id: eyesReaction.id,
})
}
async function createComment(body: string) {
console.log("Creating comment...")
return await octoRest.rest.issues.createComment({
owner,
repo,
issue_number: issueId,
body: `[Working...](${runUrl})`,
})
}
async function updateComment(body: string) {
if (!commentId) return
console.log("Updating comment...")
return await octoRest.rest.issues.updateComment({
owner,
repo,
comment_id: commentId,
body,
})
}
@@ -1029,7 +1050,7 @@ query($owner: String!, $repo: String!, $number: Int!) {
const comments = (issue.comments?.nodes || [])
.filter((c) => {
const id = parseInt(c.databaseId)
return id !== commentId && id !== payload.comment.id
return id !== payload.comment.id
})
.map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`)
@@ -1148,7 +1169,7 @@ query($owner: String!, $repo: String!, $number: Int!) {
const comments = (pr.comments?.nodes || [])
.filter((c) => {
const id = parseInt(c.databaseId)
return id !== commentId && id !== payload.comment.id
return id !== payload.comment.id
})
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
+2 -2
View File
@@ -144,7 +144,7 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: true,
useKittyKeyboard: {},
},
)
})
@@ -186,7 +186,7 @@ function App() {
// Truncate title to 40 chars max
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`oc | ${title}`)
renderer.setTerminalTitle(`OC | ${title}`)
}
})
@@ -199,7 +199,7 @@ export function Prompt(props: PromptProps) {
const content = await Editor.open({ value, renderer })
if (!content) return
input.setText(content, { history: false })
input.setText(content)
// Update positions for nonTextParts based on their location in new content
// Filter out parts whose virtual text was deleted
@@ -390,7 +390,7 @@ export function Prompt(props: PromptProps) {
input.blur()
},
set(prompt) {
input.setText(prompt.input, { history: false })
input.setText(prompt.input)
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
@@ -410,6 +410,11 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return
if (autocomplete.visible) return
if (!store.prompt.input) return
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
exit()
return
}
const selectedModel = local.model.current()
if (!selectedModel) {
promptModelWarning()
@@ -683,17 +688,6 @@ export function Prompt(props: PromptProps) {
setStore("extmarkToPartIndex", new Map())
return
}
if (keybind.match("input_forward_delete", e) && store.prompt.input !== "") {
const cursorOffset = input.cursorOffset
if (cursorOffset < input.plainText.length) {
const text = input.plainText
const newText = text.slice(0, cursorOffset) + text.slice(cursorOffset + 1)
input.setText(newText)
input.cursorOffset = cursorOffset
}
e.preventDefault()
return
}
if (keybind.match("app_exit", e)) {
await exit()
return
@@ -720,7 +714,7 @@ export function Prompt(props: PromptProps) {
const item = history.move(direction, input.plainText)
if (item) {
input.setText(item.input, { history: false })
input.setText(item.input)
setStore("prompt", item)
restoreExtmarksFromParts(item.parts)
e.preventDefault()
@@ -5,7 +5,8 @@ import { Global } from "@/global"
export function useDirectory() {
const sync = useSync()
return createMemo(() => {
const result = process.cwd().replace(Global.Path.home, "~")
const directory = sync.data.path.directory || process.cwd()
const result = directory.replace(Global.Path.home, "~")
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result
})
@@ -24,6 +24,7 @@ import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -62,6 +63,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
path: Path
}>({
provider_next: {
all: [],
@@ -86,6 +88,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {},
formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
})
const sdk = useSDK()
@@ -286,6 +289,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
sdk.client.path.get().then((x) => setStore("path", x.data!)),
]).then(() => {
setStore("status", "complete")
})
@@ -17,7 +17,7 @@
"darkAccent": "#FFF7F1",
"darkRed": "#e06c75",
"darkOrange": "#EC5B2B",
"darkGreen": "#7fd88f",
"darkBlue": "#6ba1e6",
"darkCyan": "#56b6c2",
"darkYellow": "#e5c07b",
"lightStep1": "#ffffff",
@@ -36,7 +36,7 @@
"lightAccent": "#c94d24",
"lightRed": "#d1383d",
"lightOrange": "#EC5B2B",
"lightGreen": "#3d9a57",
"lightBlue": "#0062d1",
"lightCyan": "#318795",
"lightYellow": "#b0851f"
},
@@ -62,8 +62,8 @@
"light": "lightOrange"
},
"success": {
"dark": "darkGreen",
"light": "lightGreen"
"dark": "darkBlue",
"light": "lightBlue"
},
"info": {
"dark": "darkCyan",
@@ -102,8 +102,8 @@
"light": "lightStep6"
},
"diffAdded": {
"dark": "#4fd6be",
"light": "#1e725c"
"dark": "#6ba1e6",
"light": "#0062d1"
},
"diffRemoved": {
"dark": "#c53b53",
@@ -118,16 +118,16 @@
"light": "#7086b5"
},
"diffHighlightAdded": {
"dark": "#b8db87",
"light": "#4db380"
"dark": "#6ba1e6",
"light": "#0062d1"
},
"diffHighlightRemoved": {
"dark": "#e26a75",
"light": "#f52a65"
},
"diffAddedBg": {
"dark": "#20303b",
"light": "#d5e5d5"
"dark": "#1a2a3d",
"light": "#e0edfa"
},
"diffRemovedBg": {
"dark": "#37222c",
@@ -142,8 +142,8 @@
"light": "lightStep3"
},
"diffAddedLineNumberBg": {
"dark": "#1b2b34",
"light": "#c5d5c5"
"dark": "#162535",
"light": "#d0e5f5"
},
"diffRemovedLineNumberBg": {
"dark": "#2d1f26",
@@ -166,8 +166,8 @@
"light": "lightCyan"
},
"markdownCode": {
"dark": "darkGreen",
"light": "lightGreen"
"dark": "darkBlue",
"light": "lightBlue"
},
"markdownBlockQuote": {
"dark": "#FFF7F1",
@@ -222,8 +222,8 @@
"light": "lightRed"
},
"syntaxString": {
"dark": "darkGreen",
"light": "lightGreen"
"dark": "darkBlue",
"light": "lightBlue"
},
"syntaxNumber": {
"dark": "#FFF7F1",
+4 -3
View File
@@ -1,9 +1,10 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
export const TuiEvent = {
PromptAppend: Bus.event("tui.prompt.append", z.object({ text: z.string() })),
CommandExecute: Bus.event(
PromptAppend: BusEvent.define("tui.prompt.append", z.object({ text: z.string() })),
CommandExecute: BusEvent.define(
"tui.command.execute",
z.object({
command: z.union([
@@ -27,7 +28,7 @@ export const TuiEvent = {
]),
}),
),
ToastShow: Bus.event(
ToastShow: BusEvent.define(
"tui.toast.show",
z.object({
title: z.string().optional(),
@@ -10,7 +10,7 @@ export function Footer() {
const { theme } = useTheme()
const sync = useSync()
const route = useRoute()
const mcp = createMemo(() => Object.keys(sync.data.mcp))
const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
const lsp = createMemo(() => Object.keys(sync.data.lsp))
const permissions = createMemo(() => {
@@ -66,7 +66,7 @@ export function Footer() {
<text fg={theme.text}>
<span style={{ fg: theme.success }}></span> {lsp().length} LSP
</text>
<Show when={mcp().length}>
<Show when={mcp()}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
@@ -76,7 +76,7 @@ export function Footer() {
<span style={{ fg: theme.success }}> </span>
</Match>
</Switch>
{mcp().length} MCP
{mcp()} MCP
</text>
</Show>
<text fg={theme.textMuted}>/status</text>
@@ -894,7 +894,7 @@ export function Session() {
<box marginTop={1}>
<For each={revert()!.diffFiles}>
{(file) => (
<text>
<text fg={theme.text}>
{file.filename}
<Show when={file.additions > 0}>
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
@@ -1057,6 +1057,7 @@ function UserMessage(props: {
</Show>
}
>
<span> </span>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
</Show>
</text>
@@ -1502,11 +1503,15 @@ ToolRegistry.register<typeof TaskTool>({
<Show when={props.metadata.summary?.length}>
<box>
<For each={props.metadata.summary ?? []}>
{(task) => (
<text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
{Locale.titlecase(task.tool)} {task.state.status === "completed" ? task.state.title : ""}
</text>
)}
{(task, index) => {
const summary = props.metadata.summary ?? []
return (
<text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
{index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "}
{task.state.status === "completed" ? task.state.title : ""}
</text>
)
}}
</For>
</box>
</Show>
+3 -2
View File
@@ -1,14 +1,15 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
import { Identifier } from "../id/id"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
export namespace Command {
export const Event = {
Executed: Bus.event(
Executed: BusEvent.define(
"command.executed",
z.object({
name: z.string(),
-1
View File
@@ -464,7 +464,6 @@ export namespace Config {
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_forward_delete: z.string().optional().default("ctrl+d").describe("Forward delete"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("return").describe("Submit input"),
input_newline: z.string().optional().default("shift+return,ctrl+j").describe("Insert newline in input"),
+3 -2
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { Bus } from "../bus"
import { $ } from "bun"
import type { BunFile } from "bun"
import { formatPatch, structuredPatch } from "diff"
@@ -111,7 +112,7 @@ export namespace File {
}
export const Event = {
Edited: Bus.event(
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
+5 -3
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { Bus } from "../bus"
import { Instance } from "../project/instance"
import { Log } from "../util/log"
import { FileIgnore } from "./ignore"
@@ -8,6 +9,7 @@ import { Config } from "../config/config"
import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import type ParcelWatcher from "@parcel/watcher"
import { $ } from "bun"
declare const OPENCODE_LIBC: string | undefined
@@ -15,7 +17,7 @@ export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
export const Event = {
Updated: Bus.event(
Updated: BusEvent.define(
"file.watcher.updated",
z.object({
file: z.string(),
@@ -65,7 +67,7 @@ export namespace FileWatcher {
}),
)
const vcsDir = Instance.project.vcsDir
const vcsDir = await $`git rev-parse --git-dir`.quiet().nothrow().cwd(Instance.worktree).text()
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
subs.push(
await watcher().subscribe(vcsDir, subscribe, {
+11 -2
View File
@@ -11,18 +11,27 @@ export namespace Flag {
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export const OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH =
process.env["OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH"]
// Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
export const OPENCODE_EXPERIMENTAL_WATCHER = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WATCHER")
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
export const OPENCODE_ENABLE_EXA =
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
export const OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH = number("OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH")
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
}
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
}
+3 -2
View File
@@ -1,8 +1,9 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { spawn } from "bun"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Log } from "../util/log"
import { Bus } from "../bus"
const SUPPORTED_IDES = [
{ name: "Windsurf" as const, cmd: "windsurf" },
@@ -16,7 +17,7 @@ export namespace Ide {
const log = Log.create({ service: "ide" })
export const Event = {
Installed: Bus.event(
Installed: BusEvent.define(
"ide.installed",
z.object({
ide: z.string(),
+6 -4
View File
@@ -1,8 +1,9 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import path from "path"
import { $ } from "bun"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { iife } from "@/util/iife"
@@ -17,13 +18,13 @@ export namespace Installation {
export type Method = Awaited<ReturnType<typeof method>>
export const Event = {
Updated: Bus.event(
Updated: BusEvent.define(
"installation.updated",
z.object({
version: z.string(),
}),
),
UpdateAvailable: Bus.event(
UpdateAvailable: BusEvent.define(
"installation.update-available",
z.object({
version: z.string(),
@@ -183,7 +184,8 @@ export namespace Installation {
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})
const [major] = VERSION.split(".").map((x) => Number(x))
const channel = CHANNEL === "latest" ? `latest-${major}` : CHANNEL
// const channel = CHANNEL === "latest" ? `latest-${major}` : CHANNEL
const channel = CHANNEL
return fetch(`${registry}/opencode-ai/${channel}`)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
+3 -2
View File
@@ -1,9 +1,10 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import path from "path"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import { Log } from "../util/log"
import { LANGUAGE_EXTENSIONS } from "./language"
import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "@opencode-ai/util/error"
@@ -25,7 +26,7 @@ export namespace LSPClient {
)
export const Event = {
Diagnostics: Bus.event(
Diagnostics: BusEvent.define(
"lsp.client.diagnostics",
z.object({
serverID: z.string(),
+3 -2
View File
@@ -1,3 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
@@ -6,13 +8,12 @@ import z from "zod"
import { Config } from "../config/config"
import { spawn } from "child_process"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
export namespace LSP {
const log = Log.create({ service: "lsp" })
export const Event = {
Updated: Bus.event("lsp.updated", z.object({})),
Updated: BusEvent.define("lsp.updated", z.object({})),
}
export const Range = z
+62
View File
@@ -209,6 +209,68 @@ export namespace LSPServer {
},
}
export const Biome: Info = {
id: "biome",
root: NearestRoot([
"biome.json",
"biome.jsonc",
"package-lock.json",
"bun.lockb",
"bun.lock",
"pnpm-lock.yaml",
"yarn.lock",
]),
extensions: [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".mts",
".cts",
".json",
".jsonc",
".vue",
".astro",
".svelte",
".css",
".graphql",
".gql",
".html",
],
async spawn(root) {
const localBin = path.join(root, "node_modules", ".bin", "biome")
let bin: string | undefined
if (await Bun.file(localBin).exists()) bin = localBin
if (!bin) {
const found = Bun.which("biome")
if (found) bin = found
}
let args = ["lsp-proxy", "--stdio"]
if (!bin) {
const resolved = await Bun.resolve("biome", root).catch(() => undefined)
if (!resolved) return
bin = BunProc.which()
args = ["x", "biome", "lsp-proxy", "--stdio"]
}
const proc = spawn(bin, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
}
},
}
export const Gopls: Info = {
id: "gopls",
root: async (file) => {
+4 -3
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
@@ -38,8 +39,8 @@ export namespace Permission {
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event("permission.updated", Info),
Replied: Bus.event(
Updated: BusEvent.define("permission.updated", Info),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: z.string(),
+4 -8
View File
@@ -61,14 +61,10 @@ export namespace Plugin {
for (const hook of await state().then((x) => x.hooks)) {
const fn = hook[name]
if (!fn) continue
try {
// @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
// give up.
// try-counter: 2
await fn(input, output)
} catch (e) {
log.error("failed to trigger hook", { name, error: e })
}
// @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
// give up.
// try-counter: 2
await fn(input, output)
}
return output
}
+10
View File
@@ -3,6 +3,7 @@ import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
interface Context {
directory: string
@@ -52,6 +53,15 @@ export const Instance = {
Log.Default.info("disposing instance", { directory: Instance.directory })
await State.dispose(Instance.directory)
cache.delete(Instance.directory)
GlobalBus.emit("event", {
directory: Instance.directory,
payload: {
type: "server.instance.disposed",
properties: {
directory: Instance.directory,
},
},
})
},
async disposeAll() {
Log.Default.info("disposing all instances")
+140 -57
View File
@@ -7,6 +7,10 @@ import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
import { Session } from "../session"
import { work } from "../util/queue"
import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -14,11 +18,17 @@ export namespace Project {
.object({
id: z.string(),
worktree: z.string(),
vcsDir: z.string().optional(),
vcs: z.literal("git").optional(),
name: z.string().optional(),
icon: z
.object({
url: z.string().optional(),
color: z.string().optional(),
})
.optional(),
time: z.object({
created: z.number(),
updated: z.number().optional(),
updated: z.number(),
initialized: z.number().optional(),
}),
})
@@ -27,78 +37,123 @@ export namespace Project {
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: BusEvent.define("project.updated", Info),
}
export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory })
const matches = Filesystem.up({ targets: [".git"], start: directory })
const git = await matches.next().then((x) => x.value)
await matches.return()
if (!git) {
const project: Info = {
const { id, worktree, vcs } = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
const git = await matches.next().then((x) => x.value)
await matches.return()
if (git) {
let worktree = path.dirname(git)
let id = await Bun.file(path.join(git, "opencode"))
.text()
.then((x) => x.trim())
.catch(() => {})
if (!id) {
const roots = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
id = roots[0]
if (id) Bun.file(path.join(git, "opencode")).write(id)
}
if (!id)
return {
id: "global",
worktree,
vcs: "git",
}
worktree = await $`git rev-parse --show-toplevel`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => path.resolve(worktree, x.trim()))
return { id, worktree, vcs: "git" }
}
return {
id: "global",
worktree: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
})
let existing = await Storage.read<Info>(["project", id]).catch(() => undefined)
if (!existing) {
existing = {
id,
worktree,
vcs: vcs as Info["vcs"],
time: {
created: Date.now(),
updated: Date.now(),
},
}
await Storage.write<Info>(["project", "global"], project)
return project
if (id !== "global") {
await migrateFromGlobal(id, worktree)
}
}
let worktree = path.dirname(git)
const timer = log.time("git.rev-parse")
let id = await Bun.file(path.join(git, "opencode"))
.text()
.then((x) => x.trim())
.catch(() => {})
if (!id) {
const roots = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
id = roots[0]
if (id) Bun.file(path.join(git, "opencode")).write(id)
}
timer.stop()
worktree = await $`git rev-parse --show-toplevel`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => path.resolve(worktree, x.trim()))
const vcsDir = await $`git rev-parse --git-dir`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => path.resolve(worktree, x.trim()))
const projectID = id || "global"
const existing = id ? await Storage.read<Info>(["project", id]).catch(() => undefined) : undefined
if (!existing && id) {
await migrateFromGlobal(projectID, worktree)
}
const project: Info = {
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const result: Info = {
...existing,
id: projectID,
worktree,
vcsDir,
vcs: "git",
vcs: vcs as Info["vcs"],
time: {
created: Date.now(),
...existing?.time,
...existing.time,
updated: Date.now(),
},
}
await Storage.write<Info>(["project", projectID], project)
return project
await Storage.write<Info>(["project", id], result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
return result
}
export async function discover(input: Info) {
if (input.vcs !== "git") return
if (input.icon?.url) return
const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
const matches = await Array.fromAsync(
glob.scan({
cwd: input.worktree,
absolute: true,
onlyFiles: true,
followSymlinks: false,
dot: false,
}),
)
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const file = Bun.file(shortest)
const buffer = await file.arrayBuffer()
const base64 = Buffer.from(buffer).toString("base64")
const mime = file.type || "image/png"
const url = `data:${mime};base64,${base64}`
await update({
projectID: input.id,
icon: {
url,
},
})
return
}
async function migrateFromGlobal(newProjectID: string, worktree: string) {
@@ -135,4 +190,32 @@ export namespace Project {
const keys = await Storage.list(["project"])
return await Promise.all(keys.map((x) => Storage.read<Info>(x)))
}
export const update = fn(
z.object({
projectID: z.string(),
name: z.string().optional(),
icon: Info.shape.icon.optional(),
}),
async (input) => {
const result = await Storage.update<Info>(["project", input.projectID], (draft) => {
if (input.name !== undefined) draft.name = input.name
if (input.icon !== undefined) {
draft.icon = {
...draft.icon,
}
if (input.icon.url !== undefined) draft.icon.url = input.icon.url
if (input.icon.color !== undefined) draft.icon.color = input.icon.color
}
draft.time.updated = Date.now()
})
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
return result
},
)
}
+5 -6
View File
@@ -1,8 +1,9 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { $ } from "bun"
import path from "path"
import z from "zod"
import { Log } from "@/util/log"
import { Bus } from "@/bus"
import { Instance } from "./instance"
import { FileWatcher } from "@/file/watcher"
@@ -10,7 +11,7 @@ const log = Log.create({ service: "vcs" })
export namespace Vcs {
export const Event = {
BranchUpdated: Bus.event(
BranchUpdated: BusEvent.define(
"vcs.branch.updated",
z.object({
branch: z.string().optional(),
@@ -39,16 +40,14 @@ export namespace Vcs {
const state = Instance.state(
async () => {
const vcsDir = Instance.project.vcsDir
if (Instance.project.vcs !== "git" || !vcsDir) {
if (Instance.project.vcs !== "git") {
return { branch: async () => undefined, unsubscribe: undefined }
}
let current = await currentBranch()
log.info("initialized", { branch: current })
const head = path.join(vcsDir, "HEAD")
const unsubscribe = Bus.subscribe(FileWatcher.Event.Updated, async (evt) => {
if (evt.properties.file !== head) return
if (evt.properties.file.endsWith("HEAD")) return
const next = await currentBranch()
if (next !== current) {
log.info("branch changed", { from: current, to: next })
+11
View File
@@ -12,11 +12,22 @@ export namespace ModelsDev {
export const Model = z.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: z
.object({
input: z.number(),
@@ -318,6 +318,16 @@ export namespace Provider {
},
}
},
cerebras: async () => {
return {
autoload: false,
options: {
headers: {
"X-Cerebras-3rd-Party-Integration": "opencode",
},
},
}
},
}
export const Model = z
@@ -330,6 +340,7 @@ export namespace Provider {
npm: z.string(),
}),
name: z.string(),
family: z.string().optional(),
capabilities: z.object({
temperature: z.boolean(),
reasoning: z.boolean(),
@@ -349,6 +360,12 @@ export namespace Provider {
video: z.boolean(),
pdf: z.boolean(),
}),
interleaved: z.union([
z.boolean(),
z.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
}),
]),
}),
cost: z.object({
input: z.number(),
@@ -401,6 +418,7 @@ export namespace Provider {
id: model.id,
providerID: provider.id,
name: model.name,
family: model.family,
api: {
id: model.id,
url: provider.api!,
@@ -450,6 +468,7 @@ export namespace Provider {
video: model.modalities?.output?.includes("video") ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? false,
},
interleaved: model.interleaved ?? false,
},
}
}
@@ -567,6 +586,7 @@ export namespace Provider {
video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
},
interleaved: model.interleaved ?? false,
},
cost: {
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
+26 -15
View File
@@ -74,23 +74,28 @@ export namespace ProviderTransform {
return result
}
// DeepSeek: Handle reasoning_content for tool call continuations
// - With tool calls: Include reasoning_content in providerOptions so model can continue reasoning
// - Without tool calls: Strip reasoning (new turn doesn't need previous reasoning)
// See: https://api-docs.deepseek.com/guides/thinking_mode
if (model.providerID === "deepseek" || model.api.id.toLowerCase().includes("deepseek")) {
// TODO: rm later
const bugged =
(model.id === "kimi-k2-thinking" && model.providerID === "opencode") ||
(model.id === "moonshotai/Kimi-K2-Thinking" && model.providerID === "baseten")
if (
model.providerID === "deepseek" ||
model.api.id.toLowerCase().includes("deepseek") ||
(model.capabilities.interleaved &&
typeof model.capabilities.interleaved === "object" &&
model.capabilities.interleaved.field === "reasoning_content" &&
!bugged)
) {
return msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
const hasToolCalls = msg.content.some((part: any) => part.type === "tool-call")
const reasoningText = reasoningParts.map((part: any) => part.text).join("")
// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
// If this message has tool calls and reasoning, include reasoning_content
// so DeepSeek can continue reasoning after tool execution
if (hasToolCalls && reasoningText) {
// Include reasoning_content directly on the message for all assistant messages
if (reasoningText) {
return {
...msg,
content: filteredContent,
@@ -104,12 +109,12 @@ export namespace ProviderTransform {
}
}
// For final answers (no tool calls), just strip reasoning
return {
...msg,
content: filteredContent,
}
}
return msg
})
}
@@ -212,24 +217,30 @@ export namespace ProviderTransform {
): Record<string, any> {
const result: Record<string, any> = {}
// switch to providerID later, for now use this
if (model.api.npm === "@openrouter/ai-sdk-provider") {
result["usage"] = {
include: true,
}
if (model.api.id.includes("gemini-3")) {
result["reasoning"] = { effort: "high" }
}
}
if (model.providerID === "baseten") {
result["chat_template_args"] = { enable_thinking: true }
}
if (model.providerID === "openai" || providerOptions?.setCacheKey) {
result["promptCacheKey"] = sessionID
}
if (
model.providerID === "google" ||
(model.providerID.startsWith("opencode") && model.api.id.includes("gemini-3"))
) {
if (model.api.npm === "@ai-sdk/google" || model.api.npm === "@ai-sdk/google-vertex") {
result["thinkingConfig"] = {
includeThoughts: true,
}
if (model.api.id.includes("gemini-3")) {
result["thinkingConfig"]["thinkingLevel"] = "high"
}
}
if (model.api.id.includes("gpt-5") && !model.api.id.includes("gpt-5-chat")) {
+6 -5
View File
@@ -1,8 +1,9 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { type IPty } from "bun-pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { Bus } from "../bus"
import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance"
import { shell } from "@opencode-ai/util/shell"
@@ -73,10 +74,10 @@ export namespace Pty {
export type UpdateInput = z.infer<typeof UpdateInput>
export const Event = {
Created: Bus.event("pty.created", z.object({ info: Info })),
Updated: Bus.event("pty.updated", z.object({ info: Info })),
Exited: Bus.event("pty.exited", z.object({ id: Identifier.schema("pty"), exitCode: z.number() })),
Deleted: Bus.event("pty.deleted", z.object({ id: Identifier.schema("pty") })),
Created: BusEvent.define("pty.created", z.object({ info: Info })),
Updated: BusEvent.define("pty.updated", z.object({ info: Info })),
Exited: BusEvent.define("pty.exited", z.object({ id: Identifier.schema("pty"), exitCode: z.number() })),
Deleted: BusEvent.define("pty.deleted", z.object({ id: Identifier.schema("pty") })),
}
interface ActiveSession {
+30 -1
View File
@@ -1,8 +1,10 @@
import { Hono } from "hono"
import { describeRoute } from "hono-openapi"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Instance } from "../project/instance"
import { Project } from "../project/project"
import z from "zod"
import { errors } from "./error"
export const ProjectRoute = new Hono()
.get(
@@ -48,3 +50,30 @@ export const ProjectRoute = new Hono()
return c.json(Instance.project)
},
)
.patch(
"/:projectID",
describeRoute({
summary: "Update project",
description: "Update project properties such as name, icon and color.",
operationId: "project.update",
responses: {
200: {
description: "Updated project information",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
...errors(400, 404),
},
}),
validator("param", z.object({ projectID: z.string() })),
validator("json", Project.update.schema.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const project = await Project.update({ ...body, projectID })
return c.json(project)
},
)
+54 -10
View File
@@ -1,5 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Log } from "../util/log"
import { Bus } from "../bus"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { cors } from "hono/cors"
@@ -8,7 +10,7 @@ import { proxy } from "hono/proxy"
import { Session } from "../session"
import z from "zod"
import { Provider } from "../provider/provider"
import { mapValues, pipe } from "remeda"
import { filter, mapValues, sortBy, pipe } from "remeda"
import { NamedError } from "@opencode-ai/util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../file/ripgrep"
@@ -41,7 +43,6 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
import { GlobalBus } from "@/bus/global"
import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun"
import { errors } from "./error"
@@ -54,7 +55,8 @@ export namespace Server {
const log = Log.create({ service: "server" })
export const Event = {
Connected: Bus.event("server.connected", z.object({})),
Connected: BusEvent.define("server.connected", z.object({})),
Disposed: BusEvent.define("global.disposed", z.object({})),
}
const app = new Hono()
@@ -109,7 +111,7 @@ export namespace Server {
z
.object({
directory: z.string(),
payload: Bus.payloads(),
payload: BusEvent.payloads(),
})
.meta({
ref: "GlobalEvent",
@@ -139,6 +141,35 @@ export namespace Server {
})
},
)
.post(
"/global/dispose",
describeRoute({
summary: "Dispose instance",
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
operationId: "global.dispose",
responses: {
200: {
description: "Global disposed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Instance.disposeAll()
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
})
return c.json(true)
},
)
.use(async (c, next) => {
const directory = c.req.query("directory") ?? c.req.header("x-opencode-directory") ?? process.cwd()
return Instance.provide({
@@ -482,6 +513,7 @@ export namespace Server {
schema: resolver(
z
.object({
home: z.string(),
state: z.string(),
config: z.string(),
worktree: z.string(),
@@ -498,6 +530,7 @@ export namespace Server {
}),
async (c) => {
return c.json({
home: Global.Path.home,
state: Global.Path.state,
config: Global.Path.config,
worktree: Instance.worktree,
@@ -548,7 +581,11 @@ export namespace Server {
}),
async (c) => {
const sessions = await Array.fromAsync(Session.list())
sessions.sort((a, b) => b.time.updated - a.time.updated)
pipe(
await Array.fromAsync(Session.list()),
filter((s) => !s.time.archived),
sortBy((s) => s.time.updated),
)
return c.json(sessions)
},
)
@@ -754,6 +791,9 @@ export namespace Server {
"json",
z.object({
title: z.string().optional(),
time: z.object({
archived: z.number().optional(),
}),
}),
),
async (c) => {
@@ -764,6 +804,7 @@ export namespace Server {
if (updates.title !== undefined) {
session.title = updates.title
}
if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
})
return c.json(updatedSession)
@@ -1459,12 +1500,15 @@ export namespace Server {
}
}
const providers = mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x))
const connected = await Provider.list().then((x) => Object.keys(x))
const connected = await Provider.list()
const providers = Object.assign(
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return c.json({
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected,
connected: Object.keys(connected),
})
},
)
@@ -2393,7 +2437,7 @@ export namespace Server {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(Bus.payloads()),
schema: resolver(BusEvent.payloads()),
},
},
},
+4 -3
View File
@@ -1,3 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { wrapLanguageModel, type ModelMessage } from "ai"
import { Session } from "."
import { Identifier } from "../id/id"
@@ -5,7 +7,6 @@ import { Instance } from "../project/instance"
import { Provider } from "../provider/provider"
import { MessageV2 } from "./message-v2"
import { SystemPrompt } from "./system"
import { Bus } from "../bus"
import z from "zod"
import { SessionPrompt } from "./prompt"
import { Flag } from "../flag/flag"
@@ -21,7 +22,7 @@ export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
export const Event = {
Compacted: Bus.event(
Compacted: BusEvent.define(
"session.compacted",
z.object({
sessionID: z.string(),
@@ -173,7 +174,7 @@ export namespace SessionCompaction {
content: [
{
type: "text",
text: "Summarize our conversation above. This summary will be the only context available when the conversation continues, so preserve critical information including: what was accomplished, current work in progress, files involved, next steps, and any key user requests or constraints. Be concise but detailed enough that work can continue seamlessly.",
text: "Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation.",
},
],
},
+10 -29
View File
@@ -1,7 +1,8 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import z from "zod"
import { type LanguageModelUsage, type ProviderMetadata } from "ai"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
@@ -59,6 +60,7 @@ export namespace Session {
created: z.number(),
updated: z.number(),
compacting: z.number().optional(),
archived: z.number().optional(),
}),
revert: z
.object({
@@ -85,32 +87,32 @@ export namespace Session {
export type ShareInfo = z.output<typeof ShareInfo>
export const Event = {
Created: Bus.event(
Created: BusEvent.define(
"session.created",
z.object({
info: Info,
}),
),
Updated: Bus.event(
Updated: BusEvent.define(
"session.updated",
z.object({
info: Info,
}),
),
Deleted: Bus.event(
Deleted: BusEvent.define(
"session.deleted",
z.object({
info: Info,
}),
),
Diff: Bus.event(
Diff: BusEvent.define(
"session.diff",
z.object({
sessionID: z.string(),
diff: Snapshot.FileDiff.array(),
}),
),
Error: Bus.event(
Error: BusEvent.define(
"session.error",
z.object({
sessionID: z.string().optional(),
@@ -221,34 +223,13 @@ export namespace Session {
if (cfg.share === "disabled") {
throw new Error("Sharing is disabled in configuration")
}
if (cfg.enterprise?.url) {
const { ShareNext } = await import("@/share/share-next")
const share = await ShareNext.create(id)
await update(id, (draft) => {
draft.share = {
url: share.url,
}
})
}
const session = await get(id)
if (session.share) return session.share
const { Share } = await import("../share/share")
const share = await Share.create(id)
const { ShareNext } = await import("@/share/share-next")
const share = await ShareNext.create(id)
await update(id, (draft) => {
draft.share = {
url: share.url,
}
})
await Storage.write(["share", id], share)
await Share.sync("session/info/" + id, session)
for (const msg of await messages({ sessionID: id })) {
await Share.sync("session/message/" + id + "/" + msg.info.id, msg.info)
for (const part of msg.parts) {
await Share.sync("session/part/" + id + "/" + msg.info.id + "/" + part.id, part)
}
}
return share
})
+6 -5
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { Bus } from "../bus"
import { NamedError } from "@opencode-ai/util/error"
import { Message } from "./message"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
@@ -375,27 +376,27 @@ export namespace MessageV2 {
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
Updated: BusEvent.define(
"message.updated",
z.object({
info: Info,
}),
),
Removed: Bus.event(
Removed: BusEvent.define(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
}),
),
PartUpdated: Bus.event(
PartUpdated: BusEvent.define(
"message.part.updated",
z.object({
part: Part,
delta: z.string().optional(),
}),
),
PartRemoved: Bus.event(
PartRemoved: BusEvent.define(
"message.part.removed",
z.object({
sessionID: z.string(),
+50 -36
View File
@@ -338,6 +338,7 @@ export namespace SessionPrompt {
},
},
})) as MessageV2.ToolPart
let executionError: Error | undefined
const result = await taskTool
.execute(
{
@@ -362,7 +363,11 @@ export namespace SessionPrompt {
},
},
)
.catch(() => {})
.catch((error) => {
executionError = error
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
})
assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now()
await Session.updateMessage(assistantMessage)
@@ -388,7 +393,7 @@ export namespace SessionPrompt {
...part,
state: {
status: "error",
error: "Tool execution failed",
error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed",
time: {
start: part.state.status === "running" ? part.state.time.start : Date.now(),
end: Date.now(),
@@ -515,6 +520,37 @@ export namespace SessionPrompt {
})
}
const messages = [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...MessageV2.toModelMessage(
msgs.filter((m) => {
if (m.info.role !== "assistant" || m.info.error === undefined) {
return true
}
if (
MessageV2.AbortedError.isInstance(m.info.error) &&
m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
) {
return true
}
return false
}),
),
...(isLastStep
? [
{
role: "assistant" as const,
content: MAX_STEPS,
},
]
: []),
]
const result = await processor.process({
onError(error) {
log.error("stream error", {
@@ -567,37 +603,7 @@ export namespace SessionPrompt {
temperature: params.temperature,
topP: params.topP,
toolChoice: isLastStep ? "none" : undefined,
messages: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...MessageV2.toModelMessage(
msgs.filter((m) => {
if (m.info.role !== "assistant" || m.info.error === undefined) {
return true
}
if (
MessageV2.AbortedError.isInstance(m.info.error) &&
m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
) {
return true
}
return false
}),
),
...(isLastStep
? [
{
role: "assistant" as const,
content: MAX_STEPS,
},
]
: []),
],
messages,
tools: model.capabilities.toolcall === false ? undefined : tools,
model: wrapLanguageModel({
model: language,
@@ -1230,8 +1236,8 @@ export namespace SessionPrompt {
},
}
await Session.updatePart(part)
const shell = process.env["SHELL"] ?? "bash"
const shellName = path.basename(shell)
const shell = process.env["SHELL"] ?? (process.platform === "win32" ? process.env["COMSPEC"] || "cmd.exe" : "bash")
const shellName = path.basename(shell).toLowerCase()
const invocations: Record<string, { args: string[] }> = {
nu: {
@@ -1261,6 +1267,14 @@ export namespace SessionPrompt {
`,
],
},
// Windows cmd.exe
"cmd.exe": {
args: ["/c", input.command],
},
// Windows PowerShell
"powershell.exe": {
args: ["-NoProfile", "-Command", input.command],
},
// Fallback: any shell that doesn't match those above
"": {
args: ["-c", "-l", `${input.command}`],
@@ -1272,7 +1286,7 @@ export namespace SessionPrompt {
const proc = spawn(shell, args, {
cwd: Instance.directory,
detached: true,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
+3 -2
View File
@@ -1,3 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Instance } from "@/project/instance"
import z from "zod"
@@ -24,7 +25,7 @@ export namespace SessionStatus {
export type Info = z.infer<typeof Info>
export const Event = {
Status: Bus.event(
Status: BusEvent.define(
"session.status",
z.object({
sessionID: z.string(),
@@ -32,7 +33,7 @@ export namespace SessionStatus {
}),
),
// deprecated
Idle: Bus.event(
Idle: BusEvent.define(
"session.idle",
z.object({
sessionID: z.string(),
+3 -2
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { Bus } from "../bus"
import { Storage } from "../storage/storage"
export namespace Todo {
@@ -14,7 +15,7 @@ export namespace Todo {
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
Updated: BusEvent.define(
"todo.updated",
z.object({
sessionID: z.string(),
+7 -8
View File
@@ -11,9 +11,11 @@ import type * as SDK from "@opencode-ai/sdk/v2"
export namespace ShareNext {
const log = Log.create({ service: "share-next" })
async function url() {
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
}
export async function init() {
const config = await Config.get()
if (!config.enterprise) return
Bus.subscribe(Session.Event.Updated, async (evt) => {
await sync(evt.properties.info.id, [
{
@@ -62,8 +64,7 @@ export namespace ShareNext {
export async function create(sessionID: string) {
log.info("creating share", { sessionID })
const url = await Config.get().then((x) => x.enterprise!.url)
const result = await fetch(`${url}/api/share`, {
const result = await fetch(`${await url()}/api/share`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -126,11 +127,10 @@ export namespace ShareNext {
const queued = queue.get(sessionID)
if (!queued) return
queue.delete(sessionID)
const url = await Config.get().then((x) => x.enterprise!.url)
const share = await get(sessionID)
if (!share) return
await fetch(`${url}/api/share/${share.id}/sync`, {
await fetch(`${await url()}/api/share/${share.id}/sync`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -146,10 +146,9 @@ export namespace ShareNext {
export async function remove(sessionID: string) {
log.info("removing share", { sessionID })
const url = await Config.get().then((x) => x.enterprise!.url)
const share = await get(sessionID)
if (!share) return
await fetch(`${url}/api/share/${share.id}`, {
await fetch(`${await url()}/api/share/${share.id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
+2 -6
View File
@@ -16,12 +16,8 @@ import { Flag } from "@/flag/flag.ts"
import path from "path"
import { iife } from "@/util/iife"
const DEFAULT_MAX_OUTPUT_LENGTH = 30_000
const MAX_OUTPUT_LENGTH = (() => {
const parsed = Number(Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH)
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_OUTPUT_LENGTH
})()
const DEFAULT_TIMEOUT = 2 * 60 * 1000
const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
const SIGKILL_TIMEOUT_MS = 200
export const log = Log.create({ service: "bash-tool" })
+41 -8
View File
@@ -17,14 +17,47 @@ Before executing the command, please follow these steps:
- Capture the output of the command.
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will timeout after 120000ms (2 minutes). Use the `timeout` parameter to control execution time.
- The `workdir` parameter specifies the working directory for the command. Defaults to the current working directory. Prefer setting `workdir` over using `cd` in your commands.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
- VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and List to read files.
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all opencode users have pre-installed.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes).
If not specified, commands will timeout after 120000ms (2 minutes).
- The description argument is required. You must write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being
returned to you.
- You can use the `run_in_background` parameter to run the command in the background,
which allows you to continue working while the command runs. You can monitor the output
using the Bash tool as it becomes available. You do not need to use '&' at the end of
the command when using this parameter.
- Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or
`echo` commands, unless explicitly instructed or when these commands are truly necessary
for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use Glob (NOT find or ls)
- Content search: Use Grep (NOT grep or rg)
- Read files: Use Read (NOT cat/head/tail)
- Edit files: Use Edit (NOT sed/awk)
- Write files: Use Write (NOT echo >/cat <<EOF)
- Communication: Output text directly (NOT echo/printf)
- When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple Bash tool
calls in a single message. For example, if you need to run "git status" and "git diff",
send a single message with two Bash tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single Bash
call with '&&' to chain them together (e.g., `git add . && git commit -m "message" &&
git push`). For instance, if one operation must complete before another starts (like
mkdir before cp, Write before Bash for git operations, or git add before git commit),
run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier
commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- Try to maintain your current working directory throughout the session by using
absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly
requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
# Working Directory
+1 -1
View File
@@ -2,7 +2,7 @@
- Searches file contents using regular expressions
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
- Returns file paths with at least one match sorted by modification time
- Returns file paths and line numbers with at least one match sorted by modification time
- Use this tool when you need to find files containing specific patterns
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
+1 -1
View File
@@ -93,7 +93,7 @@ export const ReadTool = Tool.define("read", {
throw new Error(`File not found: ${filepath}`)
}
const isImage = file.type.startsWith("image/")
const isImage = file.type.startsWith("image/") && file.type !== "image/svg+xml"
const isPdf = file.type === "application/pdf"
if (isImage || isPdf) {
const mime = file.type
+11 -5
View File
@@ -1,27 +1,33 @@
import { $ } from "bun"
import { realpathSync } from "fs"
import * as fs from "fs/promises"
import os from "os"
import path from "path"
// Strip null bytes from paths (defensive fix for CI environment issues)
function sanitizePath(p: string): string {
return p.replace(/\0/g, "")
}
type TmpDirOptions<T> = {
git?: boolean
init?: (dir: string) => Promise<T>
dispose?: (dir: string) => Promise<T>
}
export async function tmpdir<T>(options?: TmpDirOptions<T>) {
const dirpath = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))
await $`mkdir -p ${dirpath}`.quiet()
const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
await fs.mkdir(dirpath, { recursive: true })
if (options?.git) {
await $`git init`.cwd(dirpath).quiet()
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
}
const extra = await options?.init?.(dirpath)
const realpath = sanitizePath(await fs.realpath(dirpath))
const result = {
[Symbol.asyncDispose]: async () => {
await options?.dispose?.(dirpath)
await $`rm -rf ${dirpath}`.quiet()
// await fs.rm(dirpath, { recursive: true, force: true })
},
path: realpathSync(dirpath),
path: realpath,
extra: extra as T,
}
return result
+153
View File
@@ -0,0 +1,153 @@
import { test, expect, mock, beforeEach } from "bun:test"
// Track what options were passed to each transport constructor
const transportCalls: Array<{
type: "streamable" | "sse"
url: string
options: { authProvider?: unknown; requestInit?: RequestInit }
}> = []
// Mock the transport constructors to capture their arguments
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: class MockStreamableHTTP {
constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
transportCalls.push({
type: "streamable",
url: url.toString(),
options: options ?? {},
})
}
async start() {
throw new Error("Mock transport cannot connect")
}
},
}))
mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: class MockSSE {
constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
transportCalls.push({
type: "sse",
url: url.toString(),
options: options ?? {},
})
}
async start() {
throw new Error("Mock transport cannot connect")
}
},
}))
beforeEach(() => {
transportCalls.length = 0
})
// Import MCP after mocking
const { MCP } = await import("../../src/mcp/index")
const { Instance } = await import("../../src/project/instance")
const { tmpdir } = await import("../fixture/fixture")
test("headers are passed to transports when oauth is enabled (default)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
`${dir}/opencode.json`,
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
"test-server": {
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Trigger MCP initialization - it will fail to connect but we can check the transport options
await MCP.add("test-server", {
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
},
}).catch(() => {})
// Both transports should have been created with headers
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
for (const call of transportCalls) {
expect(call.options.requestInit).toBeDefined()
expect(call.options.requestInit?.headers).toEqual({
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
})
// OAuth should be enabled by default, so authProvider should exist
expect(call.options.authProvider).toBeDefined()
}
},
})
})
test("headers are passed to transports when oauth is explicitly disabled", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
transportCalls.length = 0
await MCP.add("test-server-no-oauth", {
type: "remote",
url: "https://example.com/mcp",
oauth: false,
headers: {
Authorization: "Bearer test-token",
},
}).catch(() => {})
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
for (const call of transportCalls) {
expect(call.options.requestInit).toBeDefined()
expect(call.options.requestInit?.headers).toEqual({
Authorization: "Bearer test-token",
})
// OAuth is disabled, so no authProvider
expect(call.options.authProvider).toBeUndefined()
}
},
})
})
test("no requestInit when headers are not provided", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
transportCalls.length = 0
await MCP.add("test-server-no-headers", {
type: "remote",
url: "https://example.com/mcp",
}).catch(() => {})
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
for (const call of transportCalls) {
// No headers means requestInit should be undefined
expect(call.options.requestInit).toBeUndefined()
}
},
})
})

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