Compare commits

...

270 Commits

Author SHA1 Message Date
Timo Clasen 1e07384364 fix: make compact command interruptible (#691)
publish / publish (push) Has been cancelled
Co-authored-by: GitHub Action <action@github.com>
2025-07-08 15:37:25 -05:00
strager 4c4739c422 fix(tool): fix ripgrep invocation on Windows (#700)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-07-08 15:36:26 -05:00
Rami Chowdhury 2d8b90a6ff feat(storage): ensure storage directory exists and handle paths correctly (#771) 2025-07-08 15:34:11 -05:00
Robb Currall a2fa7ffa42 fix: support cancelled task state (#775) 2025-07-08 15:33:39 -05:00
Frank Denis f7d6175283 Add support for the Zig Language Server (ZLS) (#756) 2025-07-08 15:31:11 -05:00
Tommy 9ed187ee52 docs: add terminal requirements (#708) 2025-07-08 15:30:05 -05:00
Gal Schlezinger 14d81e574b [config json schema] declare default values and examples for in-ide documentation (#754) 2025-07-08 15:29:07 -05:00
adamdottv 6efe8cc8df fix: env has to be string
publish / publish (push) Has been cancelled
2025-07-08 14:59:03 -05:00
adamdottv daa5fc916a fix(tui): pasting causes panic on macos 2025-07-08 14:57:17 -05:00
adamdottv c659496b96 fix(tui): model/provider arg parsing 2025-07-08 14:11:57 -05:00
Timo Clasen 21fbf21cb6 fix(copilot): add vision request header (#773) 2025-07-08 14:01:54 -05:00
adamdottv f31cbf2744 fix: image reading
publish / publish (push) Has been cancelled
2025-07-08 13:02:13 -05:00
Aiden Cline 8322f18e03 fix: display errors when using opencode run ... (#751) 2025-07-08 10:38:11 -05:00
adamdottv 562bdb95e2 fix: include symlinks in ripgrep searches 2025-07-08 10:02:19 -05:00
Dax a57ce8365d Update STATS.md 2025-07-08 10:30:02 -04:00
adamdottv 0da83ae67e feat(tui): command aliases 2025-07-08 08:20:55 -05:00
adamdottv 662d022a48 feat(tui): paste images and pdfs 2025-07-08 08:09:01 -05:00
GitHub Action 9efef03919 ignore: update download stats 2025-07-08 2025-07-08 12:04:27 +00:00
GitHub Action 7a9fb3fa92 ignore: update download stats 2025-07-08 2025-07-08 10:51:06 +00:00
adamdottv ea96ead346 feat(tui): handle --model and --prompt flags 2025-07-08 05:50:18 -05:00
Dax Raad 6100a77b85 start file watcher only for tui 2025-07-07 21:05:04 -04:00
Dax Raad c7a59ee2b1 better handling of aborting sessions
publish / publish (push) Has been cancelled
2025-07-07 20:59:00 -04:00
Jay V a272b58fe9 docs: intro 2025-07-07 17:41:46 -04:00
Dax Raad 9948fcf1b6 fix crash when running on new project
publish / publish (push) Has been cancelled
2025-07-07 17:39:52 -04:00
Dax Raad 0d50c867ff fix mcp tools corrupting session
publish / publish (push) Has been cancelled
2025-07-07 17:05:16 -04:00
Dax Raad 27f7e02f12 run: truncate prompt 2025-07-07 16:41:42 -04:00
Jay V 0f93ecd564 docs: canonical url 2025-07-07 16:37:00 -04:00
Dax Raad da909d9684 append piped stdin to prompt
publish / publish (push) Has been cancelled
2025-07-07 16:33:21 -04:00
Jay V facd851b11 docs: dynamic domain 2025-07-07 16:31:15 -04:00
Dax Raad c51de945a5 Add stdin support to run command
Allow piping content to opencode run when no message arguments are provided, enabling standard Unix pipe patterns for better CLI integration.

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

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-07-02 20:06:30 -04:00
Aiden Cline 64d5fff9a3 fix: unawaited promise causes opencode to use unenabled formatter (#625)
publish / publish (push) Has been cancelled
2025-07-02 19:19:31 -04:00
Jay V 925f695503 docs: tweak styles 2025-07-02 18:44:05 -04:00
adamdottv f1c925795d fix: typescript error 2025-07-02 16:08:41 -05:00
adamdottv c82a060eca feat(tui): file viewer, select messages 2025-07-02 16:08:11 -05:00
Ciaran McAleer 63e783ef79 Changed handling of OpenRouter requests to add some custom headers so that it can see the app (#613)
Co-authored-by: Dax Raad <d@ironbay.co>
2025-07-02 14:43:59 -04:00
Dax Raad 35d6273fb3 wip: session revert/unrevert 2025-07-02 13:10:36 -04:00
Mark Huggins b89d4a16fd fix: Copilot Premium Requests (#595) 2025-07-02 12:04:53 -04:00
Prashant Choudhary 2799a96032 fix: Ensure shared file previews use truncated content (#607)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-07-02 12:04:10 -04:00
Timo Clasen 8f4b79227c fix(formatting): check for enabled formatters (#611) 2025-07-02 12:03:42 -04:00
Dax Raad c810b6d206 wip: symbols for lsp 2025-07-02 11:35:25 -04:00
Dax Raad fa35407572 fix lazy loading 2025-07-02 11:18:25 -04:00
Dax Raad 8bbbc07aff fix filewatcher not closing cleanly
publish / publish (push) Has been cancelled
2025-07-02 11:15:12 -04:00
GitHub Action 75a21ba3ce Update download stats 2025-07-02
publish / publish (push) Has been cancelled
2025-07-02 12:26:24 +00:00
Timo Clasen 0d6fb68a88 fix(tui): no space between agent and user message (#598) 2025-07-02 05:12:49 -05:00
Jean du Plessis 242b886434 fix: Small typo in CLI --model flag description (#577) 2025-07-02 05:10:58 -05:00
Daniel Vélez caf465a9da chore: rename OpenCode to opencode (#579) 2025-07-02 05:09:51 -05:00
Dax Raad bbf77c6139 improve ripgrep download
publish / publish (push) Has been cancelled
2025-07-01 22:39:17 -04:00
Dax Raad 53b7e04b86 ci: tweaks 2025-07-01 22:25:53 -04:00
Dax Raad 9e75e3ed18 ignore: read deleted files 2025-07-01 20:45:50 -04:00
Dax Raad 6389858d41 ignore: add file status command 2025-07-01 20:44:12 -04:00
Dax Raad 7e5941e14b ignore: add file status command 2025-07-01 20:39:43 -04:00
Dax Raad c68aeed8d9 ignore: fix file read with diff 2025-07-01 20:08:42 -04:00
Aiden Cline b199a609a8 fix: handle null case if tool args are empty for todos (#588) 2025-07-01 18:25:23 -05:00
Frank 4a5a93b3f8 Temporarily add admin unshare api 2025-07-01 18:57:08 -04:00
Dax Raad e99bdcefac fix write tool timeout
publish / publish (push) Has been cancelled
2025-07-01 13:50:57 -04:00
Dax Raad 26dcb85de1 add file watcher 2025-07-01 13:45:25 -04:00
Dax Raad 11d042be25 snapshot functionality 2025-07-01 12:28:34 -04:00
adamdottv 33b5fe236a fix(tui): better message rendering performance
publish / publish (push) Has been cancelled
2025-07-01 07:57:45 -05:00
GitHub Action d56991006c Update download stats 2025-07-01 2025-07-01 12:27:09 +00:00
adamdottv 739a9f71c3 fix(tui): layout issues 2025-07-01 06:41:39 -05:00
Adam Spiers aef81fce0b docs: use correct baseUrl for astro editLink (#507)
Co-authored-by: Adam Spiers <opencode@adamspiers.org>
2025-07-01 05:31:18 -05:00
Timo Clasen 8f3d7b4038 feat: better model dialog with sorting by release date (#563) 2025-07-01 05:28:32 -05:00
Dax Raad de15e67834 fix lsp diagnostic accurancy
publish / publish (push) Has been cancelled
2025-06-30 22:48:32 -04:00
Dax Raad fea56d8de6 fix loading api key from env for openai compatible providers
publish / publish (push) Has been cancelled
2025-06-30 19:07:51 -04:00
Max Rabin 3d71be2b45 Add pyright lsp for Python (#551)
Co-authored-by: Max Rabin <max.rabin@mobileye.com>
2025-06-30 18:17:47 -04:00
adamdottv 58baca2a5b chore: typescript error 2025-06-30 15:46:18 -05:00
adamdottv ef73926db6 chore: include model release date 2025-06-30 15:46:18 -05:00
Dax Raad 9ad1687f04 optimistically boot lsp servers 2025-06-30 16:45:26 -04:00
Jeremy Mack c573270e66 chore: remove duplicate EditTool in TOOLS array (#556) 2025-06-30 15:32:15 -04:00
Dax Raad 9ebad68274 fix bash tool extra line 2025-06-30 15:31:30 -04:00
Dax Raad 03664ba588 fix formatting of bash tools
publish / publish (push) Has been cancelled
2025-06-30 15:28:59 -04:00
adamdottv 5a107b275c fix(tui): layout issues
publish / publish (push) Has been cancelled
2025-06-30 14:04:56 -05:00
Dax Raad dd5736fe5f add back in file hierarchy in system prompt but limit to 200 items
publish / publish (push) Has been cancelled
2025-06-30 14:46:46 -04:00
adamdottv 9f3ba03965 chore: rework layout primitives
publish / publish (push) Has been cancelled
2025-06-30 12:29:29 -05:00
Timo Clasen d090c08ef0 feat: update user and agent messages width and alignment (#515)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-30 11:57:56 -05:00
Dmytro Yankovskyi 68e82e4d94 fix(#467): more granular bedrock modelID based on aws region (#482) 2025-06-30 11:12:30 -04:00
Dax Raad a4aa0e6f8d docs: readme 2025-06-30 10:56:38 -04:00
GitHub Action 8c1ae2717c Update download stats 2025-06-30 2025-06-30 12:26:30 +00:00
Dax Raad 72d48759d7 add ruby formatter and lsp 2025-06-29 22:00:08 -04:00
Timo Clasen 986144b377 docs: how to disable mcp server (#543)
publish / publish (push) Has been cancelled
Co-authored-by: GitHub Action <action@github.com>
2025-06-29 21:33:30 -04:00
Dax Raad 1fdb326aa7 ignore: refactoring 2025-06-29 21:30:23 -04:00
Dax Raad 463257e7e4 add zig, python, clang, and kotlin formatters
Co-authored-by: Suhas-Koheda <Suhas-Koheda@users.noreply.github.com>
Co-authored-by: Polo123456789 <Polo123456789@users.noreply.github.com>
Co-authored-by: theodore-s-beers <theodore-s-beers@users.noreply.github.com>
Co-authored-by: TylerHillery <TylerHillery@users.noreply.github.com>
2025-06-29 21:27:35 -04:00
Dax Raad 0f41e60bd6 restructure formatters 2025-06-29 21:22:21 -04:00
Polo123456789 7df81f7b3e Formatters as plugins (#487) 2025-06-29 21:13:32 -04:00
Adam Spiers dd22cb2bb0 chore: add .editorconfig (#536)
Co-authored-by: Adam Spiers <opencode@adamspiers.org>
2025-06-29 21:12:58 -04:00
Dax Raad 248325925f fix issue with costs resetting once chat is completed
publish / publish (push) Has been cancelled
2025-06-29 19:43:03 -04:00
Dax Raad ca48a4f0fb better amazon bedrock caching with anthropic models
publish / publish (push) Has been cancelled
2025-06-29 19:27:07 -04:00
Dax 98ee5a3d87 Update STATS.md 2025-06-29 13:04:44 -04:00
GitHub Action 67480e5a1c Update download stats 2025-06-29 2025-06-29 12:23:40 +00:00
GitHub Action 2581a9b54c Update download stats 2025-06-29 2025-06-29 02:00:18 +00:00
Dax Raad 14a293e124 ci: stats 2025-06-28 21:59:14 -04:00
Dax Raad 780419ecae ci: daily stats script 2025-06-28 21:57:46 -04:00
Timo Clasen f0962e2d9c Add Option to Disable MCP Servers (#513) 2025-06-28 21:05:31 -04:00
Dax Raad 3a9584a419 fix context display
publish / publish (push) Has been cancelled
2025-06-28 21:01:53 -04:00
adamdottv 196f42cbff fix(tui): share command and error messages
publish / publish (push) Has been cancelled
2025-06-28 17:51:28 -05:00
Dax Raad 322385f6b1 patch for scroll dumping characters into input buffer
publish / publish (push) Has been cancelled
2025-06-28 11:56:47 -04:00
Dax Raad b7446cd7b9 ci: fix
publish / publish (push) Has been cancelled
2025-06-28 09:16:29 -04:00
Gal Schlezinger f618e569ab optimize edit-tool rendering (#463)
publish / publish (push) Has been cancelled
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-06-28 06:01:10 -05:00
Jay V 7b394b91e2 docs: share handle slower code blocks 2025-06-27 20:21:28 -04:00
Jay V 6a7983a4ea docs: adding more share images 2025-06-27 20:03:17 -04:00
Jay V 737146fca1 docs: tweak logo 2025-06-27 19:18:54 -04:00
Jay V 688f3fd12f Merge branch 'jeremyosih-feat/scroll-to-bottom-button' into dev 2025-06-27 19:16:46 -04:00
Jay V 145df08444 docs: share page format 2025-06-27 19:16:33 -04:00
Dax Raad 8b400515ea smooth out initial onboarding flow 2025-06-27 19:10:42 -04:00
Jay V 289797f56d docs: share cleanup title 2025-06-27 19:10:42 -04:00
adamdottv be0811ecc3 chore: rework openapi spec and use stainless sdk 2025-06-27 19:10:42 -04:00
Dax Raad 0676bcd4fd temporary patch for input lag on initial run 2025-06-27 19:10:42 -04:00
Polo123456789 d076def561 feat: Add golang file formatting (#474) 2025-06-27 19:10:42 -04:00
Wendell Misiedjan e0807d7317 fix: bunproc stdout / stderr parsing, error handling for bun ResolveMessage (#468) 2025-06-27 19:10:42 -04:00
Jay V fa2723f2d0 docs: update logo screenshot 2025-06-27 19:10:42 -04:00
Jay V 87d62514db docs: share page write tool bug 2025-06-27 19:10:42 -04:00
Dax Raad 2f8cf9146b ci: ignore 2025-06-27 19:10:42 -04:00
Dax Raad 8e0ec6b037 ci: aur 2025-06-27 19:10:42 -04:00
Dax Raad 6dc434cb83 ignore: cleanup 2025-06-27 19:10:42 -04:00
Dax Raad d972c27f03 lazy load formatters 2025-06-27 19:10:42 -04:00
Ryan Winchester 9e2bb63688 feat: add elixir file formatting (#458) 2025-06-27 19:10:42 -04:00
adamdottv 49053b66a9 fix(web): remove system prompts from share page 2025-06-27 19:10:42 -04:00
TheGoddessInari 47497aef07 scripts/hooks: Change shebang to universal /bin/sh (#453) 2025-06-27 19:10:41 -04:00
adamdottv 8455029de1 fix(tui): min width on user messages 2025-06-27 19:10:41 -04:00
Dax Raad 9f07f89384 fix formatting output going into tui 2025-06-27 19:10:41 -04:00
adamdottv d840d43e8f ignore: more metadata in app info 2025-06-27 19:10:41 -04:00
adamdottv 9ead2f3dfb fix: don't use prettier for langs it doesn't format 2025-06-27 19:10:41 -04:00
Dax Raad f3742ddbb8 ignore: run prettier 2025-06-27 19:10:41 -04:00
Dax Raad b61a841aa8 add auto formatting and experimental hooks feature 2025-06-27 19:10:41 -04:00
Jay V ebcf11e574 docs: lander tweak 2025-06-27 19:10:41 -04:00
Jay V 065f0aaddf docs: tweak lander 2025-06-27 19:10:41 -04:00
Dax Raad c0773dc7c5 smooth out initial onboarding flow 2025-06-27 16:09:59 -04:00
Jay V 1c3c74bd36 docs: share cleanup title 2025-06-27 15:31:21 -04:00
adamdottv 79bbf90b72 chore: rework openapi spec and use stainless sdk 2025-06-27 14:26:25 -05:00
Dax Raad 226a4a7f36 temporary patch for input lag on initial run
publish / publish (push) Has been cancelled
2025-06-27 14:36:03 -04:00
Polo123456789 df3b424830 feat: Add golang file formatting (#474) 2025-06-27 14:11:09 -04:00
Wendell Misiedjan 3cfd9d80bc fix: bunproc stdout / stderr parsing, error handling for bun ResolveMessage (#468) 2025-06-27 14:09:35 -04:00
Jay V e0553b8d2c docs: update logo screenshot 2025-06-27 14:04:09 -04:00
Jay V 391c837b37 docs: share page write tool bug 2025-06-27 13:25:15 -04:00
Dax Raad 5773d9d1a3 ci: ignore
publish / publish (push) Has been cancelled
2025-06-27 12:37:57 -04:00
Dax Raad ce611963c3 ci: aur
publish / publish (push) Has been cancelled
2025-06-27 12:29:13 -04:00
Dax Raad f865cacfb8 ignore: cleanup
publish / publish (push) Has been cancelled
2025-06-27 11:35:57 -04:00
Dax Raad 2ec0611f42 lazy load formatters 2025-06-27 11:33:37 -04:00
Ryan Winchester 334161a30e feat: add elixir file formatting (#458) 2025-06-27 10:15:11 -04:00
adamdottv dbb6e55226 fix(web): remove system prompts from share page 2025-06-27 06:48:44 -05:00
TheGoddessInari d0f9260559 scripts/hooks: Change shebang to universal /bin/sh (#453) 2025-06-27 07:40:22 -04:00
adamdottv d2176064e1 fix(tui): min width on user messages 2025-06-27 06:31:13 -05:00
Dax Raad ed8d277e49 fix formatting output going into tui
publish / publish (push) Has been cancelled
2025-06-27 07:29:41 -04:00
adamdottv 59b3268c64 ignore: more metadata in app info 2025-06-27 06:19:27 -05:00
adamdottv d043f67761 fix: don't use prettier for langs it doesn't format
publish / publish (push) Has been cancelled
2025-06-27 05:47:14 -05:00
Dax Raad 51bf193889 ignore: run prettier
publish / publish (push) Has been cancelled
2025-06-26 22:30:44 -04:00
Dax Raad f8b78f08b4 add auto formatting and experimental hooks feature
publish / publish (push) Has been cancelled
2025-06-26 22:17:08 -04:00
Jay V a4f32d602b docs: lander tweak 2025-06-26 19:47:58 -04:00
Jay V dc3dd21cf3 docs: tweak lander 2025-06-26 19:02:44 -04:00
Jeremy Osih b4c2fcccf5 Merge branch 'sst:dev' into feat/scroll-to-bottom-button 2025-06-27 00:41:20 +02:00
Jeremy Osih e950ad5306 feat(web): add scroll to last message button
Add intelligent floating scroll button for long conversations that:
- Only appears when scrolling down (direction-aware)
- Auto-hides after 3 seconds of inactivity
- Stays visible on hover to prevent accidental disappearance
- Uses consistent design patterns with repo styling
- Includes proper accessibility features

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

Co-Authored-By: Jeremy Osih <osih.jeremy@gmail.com>
Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-27 00:38:14 +02:00
Dax Raad 8ca713b737 disable task tool temporarily
publish / publish (push) Has been cancelled
2025-06-26 18:27:49 -04:00
Jay V 5b54554fd5 docs: edit theme doc 2025-06-26 17:56:31 -04:00
Dax Raad 4bc651f958 fix: improve JSON formatting and add piped output support for run command
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-26 17:32:00 -04:00
Jay V 3b6976a9c8 Merge branch 'rekram1-node-chore/update-config-docs' into dev 2025-06-26 17:24:03 -04:00
Jay V 863d5c1e8e docs: editing rules 2025-06-26 17:23:52 -04:00
adamdottv 97e19e9677 fix(tui): editor styles were off 2025-06-26 17:22:21 -04:00
adamdottv b27851461f feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottv 209687377a feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottv 90face1c09 fix(tui): editor width issues 2025-06-26 17:22:21 -04:00
adamdottv 936e2ce48b feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 17:22:21 -04:00
adamdottv 16ee8ee379 fix(tui): chat editor aesthetics 2025-06-26 17:22:21 -04:00
adamdottv ac39308dad fix(tui): visual issue with modal selected items in system theme 2025-06-26 17:22:21 -04:00
adamdottv 346b49219d chore: tui agents.md 2025-06-26 17:22:21 -04:00
Jay V d84c1f20c7 docs: social share 2025-06-26 17:22:17 -04:00
adamdottv dfb8777555 fix(tui): editor spinner colors 2025-06-26 17:21:53 -04:00
Jay V 008af18156 docs: share page responsive diff 2025-06-26 17:21:53 -04:00
adamdottv ab23167f80 docs: system theme 2025-06-26 17:21:53 -04:00
adamdottv b17ec46463 fix(tui): make opencode theme default 2025-06-26 17:21:53 -04:00
Adam 2e26b58d16 feat: default system theme (#419)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 17:21:53 -04:00
Mike Wallio 31b56e5a05 Fix undefined is not an object (evaluating 'G.title') (#395) 2025-06-26 17:21:53 -04:00
Juhani Pelli 47c401cf25 fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-26 17:21:53 -04:00
Dax Raad fab8dc9e6f more edit tool fixes 2025-06-26 17:21:53 -04:00
Dax Raad f39a2b1f16 integrate gemini-cli strategies for edit tool 2025-06-26 17:21:53 -04:00
Dax Raad 66830ced4e make edit tool more robust 2025-06-26 17:21:53 -04:00
Dax Raad 9d3fad754d ignore: typo 2025-06-26 17:21:53 -04:00
Dax Raad dcd3131f58 add output length errors 2025-06-26 17:21:53 -04:00
Dax Raad 3d02e07161 fix codex not working 2025-06-26 17:21:53 -04:00
Dax Raad 4dbc6a43a6 redirect uncaught errors to log file 2025-06-26 17:21:53 -04:00
adamdottv 5394b5188b fix(tui): editor styles were off
publish / publish (push) Has been cancelled
2025-06-26 15:12:26 -05:00
adamdottv 8e680b3957 feat(tui): more themes
publish / publish (push) Has been cancelled
2025-06-26 15:03:30 -05:00
adamdottv 1b8cd796d6 feat(tui): more themes 2025-06-26 14:54:32 -05:00
adamdottv 35fba793d0 fix(tui): editor width issues
publish / publish (push) Has been cancelled
2025-06-26 12:57:11 -05:00
adamdottv 5358d43b74 feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 12:47:17 -05:00
adamdottv f777347bac fix(tui): chat editor aesthetics 2025-06-26 12:44:44 -05:00
adamdottv 17c8b914df fix(tui): visual issue with modal selected items in system theme
publish / publish (push) Has been cancelled
2025-06-26 12:33:06 -05:00
adamdottv 43b467dd12 chore: tui agents.md 2025-06-26 12:28:29 -05:00
Jay V 0e0770921e docs: social share 2025-06-26 13:21:42 -04:00
adamdottv 8edbb74352 fix(tui): editor spinner colors 2025-06-26 12:21:20 -05:00
Jay V e6bfa95758 docs: share page responsive diff 2025-06-26 13:06:41 -04:00
adamdottv e4120b6287 docs: system theme 2025-06-26 11:33:02 -05:00
adamdottv ccbc9e00f2 fix(tui): make opencode theme default 2025-06-26 11:32:25 -05:00
Adam 7d13baadc8 feat: default system theme (#419)
publish / publish (push) Has been cancelled
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 10:16:07 -05:00
rekram1-node 9acc83697f chore: document AGENTS.md 2025-06-26 08:28:06 -05:00
Mike Wallio db24bf87c0 Fix undefined is not an object (evaluating 'G.title') (#395)
publish / publish (push) Has been cancelled
2025-06-25 19:40:09 -04:00
Juhani Pelli f4c0d2d2fd fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-25 19:39:51 -04:00
Dax Raad d240f4c676 more edit tool fixes
publish / publish (push) Has been cancelled
2025-06-25 19:22:54 -04:00
Dax Raad 9c90cdbe08 integrate gemini-cli strategies for edit tool
publish / publish (push) Has been cancelled
2025-06-25 17:56:14 -04:00
Dax Raad fc7af31fe5 make edit tool more robust
publish / publish (push) Has been cancelled
2025-06-25 17:10:48 -04:00
Dax Raad 2f8d23ec66 ignore: typo 2025-06-25 11:02:57 -04:00
Dax Raad 77ae3fb9b9 add output length errors 2025-06-25 11:02:09 -04:00
Dax Raad 4e7f6c47fd fix codex not working
publish / publish (push) Has been cancelled
2025-06-25 10:01:35 -04:00
Dax Raad 50469ed750 redirect uncaught errors to log file
publish / publish (push) Has been cancelled
2025-06-25 08:41:10 -04:00
Dax Raad aaab785493 better error message when bad directory is specified to start in 2025-06-24 22:28:25 -04:00
285 changed files with 27138 additions and 15740 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
end_of_line = lf
indent_style = space
indent_size = 2
max_line_length = 80
+32
View File
@@ -0,0 +1,32 @@
name: stats
on:
schedule:
- cron: "0 12 * * *" # Run daily at 12:00 UTC
workflow_dispatch: # Allow manual trigger
jobs:
stats:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Run stats script
run: bun scripts/stats.ts
- name: Commit stats
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add STATS.md
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
git push
+1
View File
@@ -5,3 +5,4 @@ node_modules
.env
.idea
.vscode
openapi.json
+10 -18
View File
@@ -1,20 +1,20 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/web/src/assets/logo-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/web/src/assets/logo-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/web/src/assets/logo-light.svg" alt="opencode logo">
<source srcset="packages/web/src/assets/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/web/src/assets/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/web/src/assets/logo-ornate-light.svg" alt="opencode logo">
</picture>
</a>
</p>
<p align="center">AI coding agent, built for the terminal.</p>
<p align="center">
<a href="https://opencode.ai/docs"><img alt="View docs" src="https://img.shields.io/badge/view-docs-blue?style=flat-square" /></a>
<a href="https://discord.gg/opencode"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
[![opencode Terminal UI](packages/web/src/assets/themes/opencode.png)](https://opencode.ai)
[![opencode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
@@ -40,6 +40,9 @@ For more info on how to configure opencode [**head over to our docs**](https://o
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
> **Note**: Please talk to us via github issues before spending time working on
> a new feature
To run opencode locally you need.
- Bun
@@ -54,14 +57,7 @@ $ bun run packages/opencode/src/index.ts
#### Development Notes
**API Client Generation**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you need to regenerate the Go client and OpenAPI specification:
```bash
$ cd packages/tui
$ go generate ./pkg/client/
```
This updates the generated Go client code that the TUI uses to communicate with the backend server.
**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients.
### FAQ
@@ -74,14 +70,10 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
#### What about Windows support?
There are some minor problems blocking opencode from working on windows. We are working on on them now. You'll need to use WSL for now.
#### What's the other repo?
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
---
**Join our community** [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
**Join our community** [Discord](https://discord.gg/opencode) | [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
+13
View File
@@ -0,0 +1,13 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | --------------- | ---------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
+71 -45
View File
@@ -5,7 +5,7 @@
"name": "opencode",
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6",
"sst": "3.17.8",
},
},
"packages/function": {
@@ -19,7 +19,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.0.0",
"version": "0.0.5",
"bin": {
"opencode": "./bin/opencode",
},
@@ -36,6 +36,7 @@
"env-paths": "3.0.0",
"hono": "4.7.10",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2",
"remeda": "2.22.3",
"ts-lsp-client": "1.0.3",
@@ -77,11 +78,12 @@
"lang-map": "0.4.0",
"luxon": "3.6.1",
"marked": "15.0.12",
"marked-shiki": "1.2.0",
"rehype-autolink-headings": "7.1.0",
"sharp": "0.32.5",
"shiki": "3.4.2",
"solid-js": "1.9.7",
"toolbeam-docs-theme": "0.3.0",
"toolbeam-docs-theme": "0.4.1",
},
"devDependencies": {
"@types/node": "catalog:",
@@ -94,30 +96,22 @@
"sharp",
"esbuild",
],
"patchedDependencies": {
"ai@4.3.16": "patches/ai@4.3.16.patch",
},
"overrides": {
"zod": "3.24.2",
},
"catalog": {
"@types/node": "22.13.9",
"ai": "4.3.16",
"ai": "5.0.0-beta.7",
"typescript": "5.8.2",
"zod": "3.24.2",
"zod": "3.25.49",
},
"packages": {
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@2.2.10", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icLGO7Q0NinnHIPgT+y1QjHVwH4HwV+brWbvM+FfCG2Afpa89PyKa3Ret91kGjZpBgM/xnj1B7K5eM+rRlsXQA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-beta.3", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-g49gMSkXy94lYvl5LRh438OR/0JCG6ol0jV+iLot7cy5HLltZlGocEuauETBu4b10mDXOd7XIjTEZoQpYFMYLQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-beta.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Z8SPncMtS3RsoXITmT7NVwrAq6M44dmw0DoUOYJqNNtCu8iMWuxB8Nxsoqpa0uEEy9R1V1ZThJAXTYgjTUxl3w=="],
"@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0-beta.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-H4K+4weOVgWqrDDeAbQWoA4U5mN4WrQPHQFdH7ynQYcnhj/pzctU9Q6mGlR5ESMWxaXxazxlOblSITlXo9bahA=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
@@ -461,12 +455,10 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@@ -491,6 +483,8 @@
"@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="],
"@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="],
@@ -511,7 +505,7 @@
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
"ai": ["ai@5.0.0-beta.7", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-beta.3", "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-oC4KzUJCQPMB7v9rCqL/rVk2ogZvI6lYiXfKjzPYHwa1zIgy329qqRLmAd3mKEDTTG6By1r0zasQu7FKmG+4gw=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -541,6 +535,8 @@
"astro-expressive-code": ["astro-expressive-code@0.41.2", "", { "dependencies": { "rehype-expressive-code": "^0.41.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-HN0jWTnhr7mIV/2e6uu4PPRNNo/k4UEgTLZqbp3MrHU+caCARveG2yZxaZVBmxyiVdYqW5Pd3u3n2zjnshixbw=="],
"async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
@@ -599,7 +595,7 @@
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -633,6 +629,8 @@
"ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
"clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
@@ -669,6 +667,8 @@
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
@@ -723,7 +723,7 @@
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
"diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
@@ -791,7 +791,7 @@
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
"eventsource-parser": ["eventsource-parser@3.0.3", "", {}, "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
@@ -939,6 +939,8 @@
"ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@@ -987,6 +989,8 @@
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isomorphic-git": ["isomorphic-git@1.32.1", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "path-browserify": "^1.0.1", "pify": "^4.0.1", "readable-stream": "^3.4.0", "sha.js": "^2.4.9", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-NZCS7qpLkCZ1M/IrujYBD31sM6pd/fMVArK4fz4I7h6m0rUW2AsYU7S7zXeABuHL6HIfW6l53b4UQ/K441CQjg=="],
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
@@ -1009,8 +1013,6 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
@@ -1037,6 +1039,8 @@
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"marked-shiki": ["marked-shiki@1.2.0", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-N924hp8veE6Mc91g5/kCNVoTU7TkeJfB2G2XEWb+k1fVA0Bck2T0rVt93d39BlOYH6ohP4Q9BFlPk+UkblhXbg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="],
@@ -1169,6 +1173,8 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minimisted": ["minimisted@2.0.1", "", { "dependencies": { "minimist": "^1.2.5" } }, "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"mri": ["mri@1.1.4", "", {}, "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w=="],
@@ -1247,7 +1253,7 @@
"pagefind": ["pagefind@1.3.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.3.0", "@pagefind/darwin-x64": "1.3.0", "@pagefind/linux-arm64": "1.3.0", "@pagefind/linux-x64": "1.3.0", "@pagefind/windows-x64": "1.3.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@@ -1257,6 +1263,8 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -1267,6 +1275,8 @@
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
"pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="],
"pino-abstract-transport": ["pino-abstract-transport@0.5.0", "", { "dependencies": { "duplexify": "^4.1.2", "split2": "^4.0.0" } }, "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ=="],
@@ -1319,8 +1329,6 @@
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
@@ -1417,6 +1425,8 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sha.js": ["sha.js@2.4.11", "", { "dependencies": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" }, "bin": { "sha.js": "./bin.js" } }, "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ=="],
"sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="],
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
@@ -1455,23 +1465,23 @@
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
"sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="],
"sst": ["sst@3.17.8", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.8", "sst-darwin-x64": "3.17.8", "sst-linux-arm64": "3.17.8", "sst-linux-x64": "3.17.8", "sst-linux-x86": "3.17.8", "sst-win32-arm64": "3.17.8", "sst-win32-x64": "3.17.8", "sst-win32-x86": "3.17.8" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-P/a9/ZsjtQRrTBerBMO1ODaVa5HVTmNLrQNJiYvu2Bgd0ov+vefQeHv6oima8HLlPwpDIPS2gxJk8BZrTZMfCA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-50P6YRMnZVItZUfB0+NzqMww2mmm4vB3zhTVtWUtGoXeiw78g1AEnVlmS28gYXPHM1P987jTvR7EON9u9ig/Dg=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-P0pnMHCmpkpcsxkWpilmeoD79LkbkoIcv6H0aeM9ArT/71/JBhvqH+HjMHSJCzni/9uR6er+nH5F+qol0UO6Bw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-vun54YA/UzprCu9p8BC4rMwFU5Cj9xrHAHYLYUp/yq4H0pfmBIiQM62nsfIKizRThe/TkBFy60EEi9myf6raYA=="],
"sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
"sst-linux-x64": ["sst-linux-x64@3.17.8", "", { "os": "linux", "cpu": "x64" }, "sha512-HqByCaLE2gEJbM20P1QRd+GqDMAiieuU53FaZA1F+AGxQi+kR82NWjrPqFcMj4dMYg8w/TWXuV+G5+PwoUmpDw=="],
"sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
"sst-linux-x86": ["sst-linux-x86@3.17.8", "", { "os": "linux", "cpu": "none" }, "sha512-bCd6QM3MejfSmdvg8I/k+aUJQIZEQJg023qmN78fv00vwlAtfECvY7tjT9E2m3LDp33pXrcRYbFOQzPu+tWFfA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-pilx0n8gm4aHJae/vNiqIwZkWF3tdwWzD/ON7hkytw+CVSZ0FXtyFW/yO/+2u3Yw0Kj0lSWPnUqYgm/eHPLwQA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
"sst-win32-x64": ["sst-win32-x64@3.17.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb0FVRyiOtESudF1V8ucW65PuHrx/iOHUamIO0JnbujWNHZBTRPB2QHN1dbewgkueYDaCmyS8lvuIImLwYJnzQ=="],
"sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
"sst-win32-x86": ["sst-win32-x86@3.17.8", "", { "os": "win32", "cpu": "none" }, "sha512-oVmFa/PoElQmfnGJlB0w6rPXiYuldiagO6AbrLMT/6oAnWerLQ8Uhv9tJWfMh3xtPLImQLTjxDo1v0AIzEv9QA=="],
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
@@ -1503,8 +1513,6 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
"tar-fs": ["tar-fs@3.0.9", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
@@ -1513,8 +1521,6 @@
"thread-stream": ["thread-stream@0.15.2", "", { "dependencies": { "real-require": "^0.1.0" } }, "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -1525,7 +1531,7 @@
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.1", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-lTI4dHZaVNQky29m7sb36Oy4tWPwxsCuFxFjF8hgGW0vpV+S6qPvI9SwsJFvdE/OHO5DoI7VMbryV1pxZHkkHQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -1599,8 +1605,6 @@
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -1677,7 +1681,7 @@
"youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="],
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"zod": ["zod@3.25.49", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
"zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="],
@@ -1689,12 +1693,22 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q=="],
"@astrojs/sitemap/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"@aws-crypto/crc32/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@@ -1713,6 +1727,8 @@
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@modelcontextprotocol/sdk/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
"@openauthjs/openauth/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
@@ -1749,10 +1765,14 @@
"astro/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"astro/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"eventsource/eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"get-source/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1765,8 +1785,12 @@
"miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
"opencontrol/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"opencontrol/zod-to-json-schema": ["zod-to-json-schema@3.24.3", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
@@ -1793,6 +1817,8 @@
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],
+5
View File
@@ -9,6 +9,9 @@ const bucket = new sst.cloudflare.Bucket("Bucket")
export const api = new sst.cloudflare.Worker("Api", {
domain: `api.${domain}`,
handler: "packages/function/src/api.ts",
environment: {
WEB_DOMAIN: domain,
},
url: true,
link: [bucket],
transform: {
@@ -36,6 +39,8 @@ new sst.cloudflare.x.Astro("Web", {
domain,
path: "packages/web",
environment: {
// For astro config
SST_STAGE: $app.stage,
VITE_API_URL: api.url,
},
})
+23 -1
View File
@@ -1,3 +1,25 @@
{
"$schema": "https://opencode.ai/config.json"
"$schema": "https://opencode.ai/config.json",
"mcp": {
"weather": {
"type": "local",
"command": ["opencode", "x", "@h1deya/mcp-server-weather"]
}
},
"experimental": {
"hook": {
"file_edited": {
".json": [
{
"command": ["bun", "run", "prettier", "$FILE"]
}
]
},
"session_completed": [
{
"command": ["touch", "./node_modules/foo"]
}
]
}
}
}
+7 -7
View File
@@ -5,7 +5,9 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"dev": "bun run packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"stainless": "./scripts/stainless",
"postinstall": "./scripts/hooks"
},
"workspaces": {
@@ -15,13 +17,13 @@
"catalog": {
"typescript": "5.8.2",
"@types/node": "22.13.9",
"zod": "3.24.2",
"ai": "4.3.16"
"zod": "3.25.49",
"ai": "5.0.0-beta.7"
}
},
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6"
"sst": "3.17.8"
},
"repository": {
"type": "git",
@@ -29,10 +31,8 @@
},
"license": "MIT",
"prettier": {
"semi": false
},
"overrides": {
"zod": "3.24.2"
"semi": false,
"printWidth": 120
},
"trustedDependencies": [
"esbuild",
+33 -19
View File
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"
type Env = {
SYNC_SERVER: DurableObjectNamespace<SyncServer>
Bucket: R2Bucket
WEB_DOMAIN: string
}
export class SyncServer extends DurableObject<Env> {
@@ -35,13 +36,9 @@ export class SyncServer extends DurableObject<Env> {
ws.close(code, "Durable Object is closing WebSocket")
}
async publish(secret: string, key: string, content: any) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
async publish(key: string, content: any) {
const sessionID = await this.getSessionID()
if (
!key.startsWith(`session/info/${sessionID}`) &&
!key.startsWith(`session/message/${sessionID}/`)
)
if (!key.startsWith(`session/info/${sessionID}`) && !key.startsWith(`session/message/${sessionID}/`))
return new Response("Error: Invalid key", { status: 400 })
// store message
@@ -76,6 +73,10 @@ export class SyncServer extends DurableObject<Env> {
.map(([key, content]) => ({ key, content }))
}
public async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
private async getSecret() {
return this.ctx.storage.get<string>("secret")
}
@@ -84,15 +85,19 @@ export class SyncServer extends DurableObject<Env> {
return this.ctx.storage.get<string>("sessionID")
}
async clear(secret: string) {
await this.assertSecret(secret)
async clear() {
const sessionID = await this.getSessionID()
const list = await this.env.Bucket.list({
prefix: `session/message/${sessionID}/`,
limit: 1000,
})
for (const item of list.objects) {
await this.env.Bucket.delete(item.key)
}
await this.env.Bucket.delete(`session/info/${sessionID}`)
await this.ctx.storage.deleteAll()
}
private async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
static shortName(id: string) {
return id.substring(id.length - 8)
}
@@ -120,7 +125,7 @@ export default {
return new Response(
JSON.stringify({
secret,
url: "https://opencode.ai/s/" + short,
url: `https://${env.WEB_DOMAIN}/s/${short}`,
}),
{
headers: { "Content-Type": "application/json" },
@@ -134,7 +139,17 @@ export default {
const secret = body.secret
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
const stub = env.SYNC_SERVER.get(id)
await stub.clear(secret)
await stub.assertSecret(secret)
await stub.clear()
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
}
if (request.method === "POST" && method === "share_delete_admin") {
const id = env.SYNC_SERVER.idFromName("oVF8Rsiv")
const stub = env.SYNC_SERVER.get(id)
await stub.clear()
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
@@ -150,7 +165,8 @@ export default {
const name = SyncServer.shortName(body.sessionID)
const id = env.SYNC_SERVER.idFromName(name)
const stub = env.SYNC_SERVER.get(id)
await stub.publish(body.secret, body.key, body.content)
await stub.assertSecret(body.secret)
await stub.publish(body.key, body.content)
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
@@ -165,8 +181,7 @@ export default {
}
const id = url.searchParams.get("id")
console.log("share_poll", id)
if (!id)
return new Response("Error: Share ID is required", { status: 400 })
if (!id) return new Response("Error: Share ID is required", { status: 400 })
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
return stub.fetch(request)
}
@@ -174,8 +189,7 @@ export default {
if (request.method === "GET" && method === "share_data") {
const id = url.searchParams.get("id")
console.log("share_data", id)
if (!id)
return new Response("Error: Share ID is required", { status: 400 })
if (!id) return new Response("Error: Share ID is required", { status: 400 })
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
const data = await stub.getData()
+8 -8
View File
@@ -6,20 +6,20 @@
import "sst"
declare module "sst" {
export interface Resource {
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
Web: {
type: "sst.cloudflare.Astro"
url: string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
// cloudflare
import * as cloudflare from "@cloudflare/workers-types"
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"Bucket": cloudflare.R2Bucket
Api: cloudflare.Service
Bucket: cloudflare.R2Bucket
}
}
import "sst"
export {}
export {}
-1
View File
@@ -1,4 +1,3 @@
node_modules
research
dist
gen
+1 -2
View File
@@ -7,7 +7,6 @@
- **Typecheck**: `bun run typecheck` (npm run typecheck)
- **Test**: `bun test` (runs all tests)
- **Single test**: `bun test test/tool/tool.test.ts` (specific test file)
- **API Client Generation**: `cd packages/tui && go generate ./pkg/client/` (after changes to server endpoints)
## Code Style
@@ -38,4 +37,4 @@
- **Validation**: All inputs validated with Zod schemas
- **Logging**: Use `Log.create({ service: "name" })` pattern
- **Storage**: Use `Storage` namespace for persistence
- **API Client**: Go TUI communicates with TypeScript server via generated client. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, run `cd packages/tui && go generate ./pkg/client/` to update the Go client code and OpenAPI spec.
- **API Client**: Go TUI communicates with TypeScript server via stainless SDK. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, ask the user to generate a new client SDK to proceed with client-side changes.
+1 -1
View File
@@ -49,7 +49,7 @@ else
done
if [ -z "$resolved" ]; then
printf "It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
printf "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
exit 1
fi
fi
+2 -2
View File
@@ -48,9 +48,9 @@ set "current_dir=%parent_dir%"
goto :search_loop
:not_found
echo It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the "%name%" package >&2
echo It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "%name%" package >&2
exit /b 1
:execute
rem Execute the binary with all arguments
"%resolved%" %*
"%resolved%" %*
+280 -36
View File
@@ -14,111 +14,267 @@
"properties": {
"leader": {
"type": "string",
"description": "Leader key for keybind combinations"
"default": "ctrl+x",
"description": "Leader key for keybind combinations\n\ndefault: `ctrl+x`",
"examples": [
"ctrl+x"
]
},
"help": {
"app_help": {
"type": "string",
"description": "Show help dialog"
"default": "<leader>h",
"description": "Show help dialog\n\ndefault: `<leader>h`",
"examples": [
"<leader>h"
]
},
"editor_open": {
"type": "string",
"description": "Open external editor"
"default": "<leader>e",
"description": "Open external editor\n\ndefault: `<leader>e`",
"examples": [
"<leader>e"
]
},
"session_new": {
"type": "string",
"description": "Create a new session"
"default": "<leader>n",
"description": "Create a new session\n\ndefault: `<leader>n`",
"examples": [
"<leader>n"
]
},
"session_list": {
"type": "string",
"description": "List all sessions"
"default": "<leader>l",
"description": "List all sessions\n\ndefault: `<leader>l`",
"examples": [
"<leader>l"
]
},
"session_share": {
"type": "string",
"description": "Share current session"
"default": "<leader>s",
"description": "Share current session\n\ndefault: `<leader>s`",
"examples": [
"<leader>s"
]
},
"session_unshare": {
"type": "string",
"default": "<leader>u",
"description": "Unshare current session\n\ndefault: `<leader>u`",
"examples": [
"<leader>u"
]
},
"session_interrupt": {
"type": "string",
"description": "Interrupt current session"
"default": "esc",
"description": "Interrupt current session\n\ndefault: `esc`",
"examples": [
"esc"
]
},
"session_compact": {
"type": "string",
"description": "Toggle compact mode for session"
"default": "<leader>c",
"description": "Compact the session\n\ndefault: `<leader>c`",
"examples": [
"<leader>c"
]
},
"tool_details": {
"type": "string",
"description": "Show tool details"
"default": "<leader>d",
"description": "Toggle tool details\n\ndefault: `<leader>d`",
"examples": [
"<leader>d"
]
},
"model_list": {
"type": "string",
"description": "List available models"
"default": "<leader>m",
"description": "List available models\n\ndefault: `<leader>m`",
"examples": [
"<leader>m"
]
},
"theme_list": {
"type": "string",
"description": "List available themes"
"default": "<leader>t",
"description": "List available themes\n\ndefault: `<leader>t`",
"examples": [
"<leader>t"
]
},
"file_list": {
"type": "string",
"default": "<leader>f",
"description": "List files\n\ndefault: `<leader>f`",
"examples": [
"<leader>f"
]
},
"file_close": {
"type": "string",
"default": "esc",
"description": "Close file\n\ndefault: `esc`",
"examples": [
"esc"
]
},
"file_search": {
"type": "string",
"default": "<leader>/",
"description": "Search file\n\ndefault: `<leader>/`",
"examples": [
"<leader>/"
]
},
"file_diff_toggle": {
"type": "string",
"default": "<leader>v",
"description": "Split/unified diff\n\ndefault: `<leader>v`",
"examples": [
"<leader>v"
]
},
"project_init": {
"type": "string",
"description": "Initialize project configuration"
"default": "<leader>i",
"description": "Create/update AGENTS.md\n\ndefault: `<leader>i`",
"examples": [
"<leader>i"
]
},
"input_clear": {
"type": "string",
"description": "Clear input field"
"default": "ctrl+c",
"description": "Clear input field\n\ndefault: `ctrl+c`",
"examples": [
"ctrl+c"
]
},
"input_paste": {
"type": "string",
"description": "Paste from clipboard"
"default": "ctrl+v",
"description": "Paste from clipboard\n\ndefault: `ctrl+v`",
"examples": [
"ctrl+v"
]
},
"input_submit": {
"type": "string",
"description": "Submit input"
"default": "enter",
"description": "Submit input\n\ndefault: `enter`",
"examples": [
"enter"
]
},
"input_newline": {
"type": "string",
"description": "Insert newline in input"
},
"history_previous": {
"type": "string",
"description": "Navigate to previous history item"
},
"history_next": {
"type": "string",
"description": "Navigate to next history item"
"default": "shift+enter,ctrl+j",
"description": "Insert newline in input\n\ndefault: `shift+enter,ctrl+j`",
"examples": [
"shift+enter,ctrl+j"
]
},
"messages_page_up": {
"type": "string",
"description": "Scroll messages up by one page"
"default": "pgup",
"description": "Scroll messages up by one page\n\ndefault: `pgup`",
"examples": [
"pgup"
]
},
"messages_page_down": {
"type": "string",
"description": "Scroll messages down by one page"
"default": "pgdown",
"description": "Scroll messages down by one page\n\ndefault: `pgdown`",
"examples": [
"pgdown"
]
},
"messages_half_page_up": {
"type": "string",
"description": "Scroll messages up by half page"
"default": "ctrl+alt+u",
"description": "Scroll messages up by half page\n\ndefault: `ctrl+alt+u`",
"examples": [
"ctrl+alt+u"
]
},
"messages_half_page_down": {
"type": "string",
"description": "Scroll messages down by half page"
"default": "ctrl+alt+d",
"description": "Scroll messages down by half page\n\ndefault: `ctrl+alt+d`",
"examples": [
"ctrl+alt+d"
]
},
"messages_previous": {
"type": "string",
"description": "Navigate to previous message"
"default": "ctrl+up",
"description": "Navigate to previous message\n\ndefault: `ctrl+up`",
"examples": [
"ctrl+up"
]
},
"messages_next": {
"type": "string",
"description": "Navigate to next message"
"default": "ctrl+down",
"description": "Navigate to next message\n\ndefault: `ctrl+down`",
"examples": [
"ctrl+down"
]
},
"messages_first": {
"type": "string",
"description": "Navigate to first message"
"default": "ctrl+g",
"description": "Navigate to first message\n\ndefault: `ctrl+g`",
"examples": [
"ctrl+g"
]
},
"messages_last": {
"type": "string",
"description": "Navigate to last message"
"default": "ctrl+alt+g",
"description": "Navigate to last message\n\ndefault: `ctrl+alt+g`",
"examples": [
"ctrl+alt+g"
]
},
"messages_layout_toggle": {
"type": "string",
"default": "<leader>p",
"description": "Toggle layout\n\ndefault: `<leader>p`",
"examples": [
"<leader>p"
]
},
"messages_copy": {
"type": "string",
"default": "<leader>y",
"description": "Copy message\n\ndefault: `<leader>y`",
"examples": [
"<leader>y"
]
},
"messages_revert": {
"type": "string",
"default": "<leader>r",
"description": "Revert message\n\ndefault: `<leader>r`",
"examples": [
"<leader>r"
]
},
"app_exit": {
"type": "string",
"description": "Exit the application"
"default": "ctrl+c,<leader>q",
"description": "Exit the application\n\ndefault: `ctrl+c,<leader>q`",
"examples": [
"ctrl+c,<leader>q"
]
}
},
"additionalProperties": false,
@@ -171,9 +327,15 @@
"additionalProperties": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"release_date": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
@@ -183,6 +345,9 @@
"temperature": {
"type": "boolean"
},
"tool_call": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
@@ -221,8 +386,9 @@
],
"additionalProperties": false
},
"id": {
"type": "string"
"options": {
"type": "object",
"additionalProperties": {}
}
},
"additionalProperties": false
@@ -265,6 +431,10 @@
"type": "string"
},
"description": "Environment variables to set when running the MCP server"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
}
},
"required": [
@@ -284,6 +454,10 @@
"url": {
"type": "string",
"description": "URL of the remote MCP server"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
}
},
"required": [
@@ -295,6 +469,76 @@
]
},
"description": "MCP (Model Context Protocol) server configurations"
},
"instructions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Additional instruction files or patterns to include"
},
"experimental": {
"type": "object",
"properties": {
"hook": {
"type": "object",
"properties": {
"file_edited": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
],
"additionalProperties": false
}
}
},
"session_completed": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
],
"additionalProperties": false
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0",
"version": "0.0.5",
"name": "opencode",
"type": "module",
"private": true,
@@ -37,6 +37,7 @@
"env-paths": "3.0.0",
"hono": "4.7.10",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2",
"remeda": "2.22.3",
"ts-lsp-client": "1.0.3",
+21 -38
View File
@@ -40,7 +40,7 @@ for (const [os, arch] of targets) {
console.log(`building ${os}-${arch}`)
const name = `${pkg.name}-${os}-${arch}`
await $`mkdir -p dist/${name}/bin`
await $`GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
"../tui",
)
await $`bun build --define OPENCODE_VERSION="'${version}'" --compile --minify --target=bun-${os}-${arch} --outfile=dist/${name}/bin/opencode ./src/index.ts ./dist/${name}/bin/tui`
@@ -57,8 +57,7 @@ for (const [os, arch] of targets) {
2,
),
)
if (!dry)
await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
if (!dry) await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
optionalDependencies[name] = version
}
@@ -82,8 +81,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
2,
),
)
if (!dry)
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
if (!dry) await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
if (!snapshot) {
// Github Release
@@ -91,15 +89,11 @@ if (!snapshot) {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
}
const previous = await fetch(
"https://api.github.com/repos/sst/opencode/releases/latest",
)
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json())
.then((data) => data.tag_name)
const commits = await fetch(
`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`,
)
const commits = await fetch(`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`)
.then((res) => res.json())
.then((data) => data.commits || [])
@@ -110,39 +104,27 @@ if (!snapshot) {
return (
!lower.includes("ignore:") &&
!lower.includes("ci:") &&
!lower.includes("wip:") &&
!lower.includes("docs:") &&
!lower.includes("doc:")
)
})
.join("\n")
if (!dry)
await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
if (!dry) await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
// Calculate SHA values
const arm64Sha =
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const x64Sha =
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macX64Sha =
await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macArm64Sha =
await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
// AUR package
const pkgbuild = [
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='opencode-bin'",
"pkgname='${pkg}'",
`pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')",
"pkgrel=1",
@@ -166,14 +148,15 @@ if (!snapshot) {
"",
].join("\n")
await $`rm -rf ./dist/aur-opencode-bin`
await $`git clone ssh://aur@aur.archlinux.org/opencode-bin.git ./dist/aur-opencode-bin`
await Bun.file("./dist/aur-opencode-bin/PKGBUILD").write(pkgbuild)
await $`cd ./dist/aur-opencode-bin && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-opencode-bin && git push`
for (const pkg of ["opencode", "opencode-bin"]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild.replace("${pkg}", pkg))
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
}
// Homebrew formula
const homebrewFormula = [
+26 -1
View File
@@ -4,5 +4,30 @@ import "zod-openapi/extend"
import { Config } from "../src/config/config"
import { zodToJsonSchema } from "zod-to-json-schema"
const result = zodToJsonSchema(Config.Info)
const result = zodToJsonSchema(Config.Info, {
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
postProcess(jsonSchema) {
const schema = jsonSchema as typeof jsonSchema & {
examples?: unknown[]
}
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
return jsonSchema
},
})
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
+33 -31
View File
@@ -13,6 +13,7 @@ export namespace App {
export const Info = z
.object({
user: z.string(),
hostname: z.string(),
git: z.boolean(),
path: z.object({
config: z.string(),
@@ -26,28 +27,32 @@ export namespace App {
}),
})
.openapi({
ref: "App.Info",
ref: "App",
})
export type Info = z.infer<typeof Info>
const ctx = Context.create<Awaited<ReturnType<typeof create>>>("app")
const ctx = Context.create<{
info: Info
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
}>("app")
export const use = ctx.use
const APP_JSON = "app.json"
async function create(input: { cwd: string }) {
export type Input = {
cwd: string
}
export const provideExisting = ctx.provide
export async function provide<T>(input: Input, cb: (app: App.Info) => Promise<T>) {
log.info("creating", {
cwd: input.cwd,
})
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) =>
x ? path.dirname(x) : undefined,
)
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) => (x ? path.dirname(x) : undefined))
log.info("git", { git })
const data = path.join(
Global.Path.data,
"project",
git ? directory(git) : "global",
)
const data = path.join(Global.Path.data, "project", git ? directory(git) : "global")
const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as {
initialized: number
@@ -62,8 +67,11 @@ export namespace App {
}
>()
const root = git ?? input.cwd
const info: Info = {
user: os.userInfo().username,
hostname: os.hostname(),
time: {
initialized: state.initialized,
},
@@ -72,16 +80,27 @@ export namespace App {
config: Global.Path.config,
state: Global.Path.state,
data,
root: git ?? input.cwd,
root,
cwd: input.cwd,
},
}
const result = {
const app = {
services,
info,
}
return result
return ctx.provide(app, async () => {
try {
const result = await cb(app.info)
return result
} finally {
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
}
})
}
export function state<State>(
@@ -107,22 +126,6 @@ export namespace App {
return ctx.use().info
}
export async function provide<T>(
input: { cwd: string },
cb: (app: Info) => Promise<T>,
) {
const app = await create(input)
return ctx.provide(app, async () => {
const result = await cb(app.info)
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
return result
})
}
export async function initialize() {
const { info } = ctx.use()
info.time.initialized = Date.now()
@@ -142,4 +145,3 @@ export namespace App {
.replace(/[^A-Za-z0-9_]/g, "-")
}
}
+12 -21
View File
@@ -10,14 +10,8 @@ export namespace AuthAnthropic {
url.searchParams.set("code", "true")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("response_type", "code")
url.searchParams.set(
"redirect_uri",
"https://console.anthropic.com/oauth/code/callback",
)
url.searchParams.set(
"scope",
"org:create_api_key user:profile user:inference",
)
url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
url.searchParams.set("scope", "org:create_api_key user:profile user:inference")
url.searchParams.set("code_challenge", pkce.challenge)
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("state", pkce.verifier)
@@ -57,20 +51,17 @@ export namespace AuthAnthropic {
const info = await Auth.get("anthropic")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
const response = await fetch(
"https://console.anthropic.com/v1/oauth/token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: info.refresh,
client_id: CLIENT_ID,
}),
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
)
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: info.refresh,
client_id: CLIENT_ID,
}),
})
if (!response.ok) return
const json = await response.json()
await Auth.set("anthropic", {
+1 -3
View File
@@ -4,9 +4,7 @@ import path from "path"
export const AuthCopilot = lazy(async () => {
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
const response = fetch(
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
)
const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts")
.then((x) => Bun.write(file, x))
.catch(() => {})
+1 -4
View File
@@ -122,10 +122,7 @@ export namespace AuthGithubCopilot {
return tokenData.token
}
export const DeviceCodeError = NamedError.create(
"DeviceCodeError",
z.object({}),
)
export const DeviceCodeError = NamedError.create("DeviceCodeError", z.object({}))
export const TokenExchangeError = NamedError.create(
"TokenExchangeError",
+14 -10
View File
@@ -3,14 +3,12 @@ import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { NamedError } from "../util/error"
import { readableStreamToText } from "bun"
export namespace BunProc {
const log = Log.create({ service: "bun" })
export async function run(
cmd: string[],
options?: Bun.SpawnOptions.OptionsObject<any, any, any>,
) {
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
log.info("running", {
cmd: [which(), ...cmd],
...options,
@@ -26,10 +24,16 @@ export namespace BunProc {
},
})
const code = await result.exited
// @ts-ignore
const stdout = await result.stdout.text()
// @ts-ignore
const stderr = await result.stderr.text()
const stdout = result.stdout
? typeof result.stdout === "number"
? result.stdout
: await readableStreamToText(result.stdout)
: undefined
const stderr = result.stderr
? typeof result.stderr === "number"
? result.stderr
: await readableStreamToText(result.stderr)
: undefined
log.info("done", {
code,
stdout,
@@ -62,10 +66,10 @@ export namespace BunProc {
if (parsed.dependencies[pkg] === version) return mod
parsed.dependencies[pkg] = version
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
await BunProc.run(["install", "--cwd", Global.Path.cache, "--registry=https://registry.npmjs.org"], {
cwd: Global.Path.cache,
}).catch((e) => {
new InstallFailedError(
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
+6 -10
View File
@@ -18,10 +18,7 @@ export namespace Bus {
const registry = new Map<string, EventDefinition>()
export function event<Type extends string, Properties extends ZodType>(
type: Type,
properties: Properties,
) {
export function event<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
@@ -49,7 +46,7 @@ export namespace Bus {
)
}
export function publish<Definition extends EventDefinition>(
export async function publish<Definition extends EventDefinition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
@@ -60,20 +57,19 @@ export namespace Bus {
log.info("publishing", {
type: def.type,
})
const pending = []
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key)
for (const sub of match ?? []) {
sub(payload)
pending.push(sub(payload))
}
}
return Promise.all(pending)
}
export function subscribe<Definition extends EventDefinition>(
def: Definition,
callback: (event: {
type: Definition["type"]
properties: z.infer<Definition["properties"]>
}) => void,
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
) {
return raw(def.type, callback)
}
+16
View File
@@ -0,0 +1,16 @@
import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Share } from "../share/share"
export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Promise<T>) {
return App.provide(input, async (app) => {
Share.init()
Format.init()
ConfigHooks.init()
LSP.init()
return cb(app)
})
}
+13 -29
View File
@@ -15,11 +15,7 @@ export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
builder: (yargs) =>
yargs
.command(AuthLoginCommand)
.command(AuthLogoutCommand)
.command(AuthListCommand)
.demandCommand(),
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
async handler() {},
})
@@ -31,9 +27,7 @@ export const AuthListCommand = cmd({
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir)
? authPath.replace(homedir, "~")
: authPath
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await Auth.all().then((x) => Object.entries(x))
const database = await ModelsDev.get()
@@ -46,14 +40,14 @@ export const AuthListCommand = cmd({
prompts.outro(`${results.length} credentials`)
// Environment variables section
const activeEnvVars: Array<{ provider: string, envVar: string }> = []
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
@@ -62,11 +56,11 @@ export const AuthListCommand = cmd({
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variables`)
}
},
@@ -114,8 +108,7 @@ export const AuthLoginCommand = cmd({
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) =>
x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only",
validate: (x) => (x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
@@ -186,17 +179,13 @@ export const AuthLoginCommand = cmd({
await new Promise((resolve) => setTimeout(resolve, 10))
const deviceInfo = await copilot.authorize()
prompts.note(
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
)
prompts.note(`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`)
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
while (true) {
await new Promise((resolve) =>
setTimeout(resolve, deviceInfo.interval * 1000),
)
await new Promise((resolve) => setTimeout(resolve, deviceInfo.interval * 1000))
const response = await copilot.poll(deviceInfo.device)
if (response.status === "pending") continue
if (response.status === "success") {
@@ -248,12 +237,7 @@ export const AuthLogoutCommand = cmd({
const providerID = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label:
(database[key]?.name || key) +
UI.Style.TEXT_DIM +
" (" +
value.type +
")",
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
@@ -0,0 +1,36 @@
import { File } from "../../../file"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
const FileReadCommand = cmd({
command: "read <path>",
builder: (yargs) =>
yargs.positional("path", {
type: "string",
demandOption: true,
description: "File path to read",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const content = await File.read(args.path)
console.log(content)
})
},
})
const FileStatusCommand = cmd({
command: "status",
builder: (yargs) => yargs,
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const status = await File.status()
console.log(JSON.stringify(status, null, 2))
})
},
})
export const FileCommand = cmd({
command: "file",
builder: (yargs) => yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
async handler() {},
})
@@ -0,0 +1,26 @@
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
import { SnapshotCommand } from "./snapshot"
export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)
.command(SnapshotCommand)
.command({
command: "wait",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
})
},
})
.demandCommand(),
async handler() {},
})
@@ -0,0 +1,34 @@
import { LSP } from "../../../lsp"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
export const LSPCommand = cmd({
command: "lsp",
builder: (yargs) => yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
async handler() {},
})
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
export const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
})
},
})
@@ -0,0 +1,82 @@
import { App } from "../../../app/app"
import { Ripgrep } from "../../../file/ripgrep"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const RipgrepCommand = cmd({
command: "rg",
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
async handler() {},
})
const TreeCommand = cmd({
command: "tree",
builder: (yargs) =>
yargs.option("limit", {
type: "number",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
})
},
})
const FilesCommand = cmd({
command: "files",
builder: (yargs) =>
yargs
.option("query", {
type: "string",
description: "Filter files by query",
})
.option("glob", {
type: "string",
description: "Glob pattern to match files",
})
.option("limit", {
type: "number",
description: "Limit number of results",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
const files = await Ripgrep.files({
cwd: app.path.cwd,
query: args.query,
glob: args.glob,
limit: args.limit,
})
console.log(files.join("\n"))
})
},
})
const SearchCommand = cmd({
command: "search <pattern>",
builder: (yargs) =>
yargs
.positional("pattern", {
type: "string",
demandOption: true,
description: "Search pattern",
})
.option("glob", {
type: "array",
description: "File glob patterns",
})
.option("limit", {
type: "number",
description: "Limit number of results",
}),
async handler(args) {
const results = await Ripgrep.search({
cwd: process.cwd(),
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
})
console.log(JSON.stringify(results, null, 2))
},
})
@@ -0,0 +1,35 @@
import { Snapshot } from "../../../snapshot"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
builder: (yargs) => yargs.command(SnapshotCreateCommand).command(SnapshotRestoreCommand).demandCommand(),
async handler() {},
})
export const SnapshotCreateCommand = cmd({
command: "create",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const result = await Snapshot.create("test")
console.log(result)
})
},
})
export const SnapshotRestoreCommand = cmd({
command: "restore <commit>",
builder: (yargs) =>
yargs.positional("commit", {
type: "string",
description: "commit",
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await Snapshot.restore("test", args.commit)
console.log("restored")
})
},
})
+1 -4
View File
@@ -10,9 +10,6 @@ export const GenerateCommand = {
const dir = "gen"
await fs.rmdir(dir, { recursive: true }).catch(() => {})
await fs.mkdir(dir, { recursive: true })
await Bun.write(
path.join(dir, "openapi.json"),
JSON.stringify(specs, null, 2),
)
await Bun.write(path.join(dir, "openapi.json"), JSON.stringify(specs, null, 2))
},
} satisfies CommandModule
+2 -2
View File
@@ -8,7 +8,7 @@ export const ModelsCommand = cmd({
handler: async () => {
await App.provide({ cwd: process.cwd() }, async () => {
const providers = await Provider.list()
for (const [providerID, provider] of Object.entries(providers)) {
for (const modelID of Object.keys(provider.info.models)) {
console.log(`${providerID}/${modelID}`)
@@ -16,4 +16,4 @@ export const ModelsCommand = cmd({
}
})
},
})
})
+93 -96
View File
@@ -1,14 +1,13 @@
import type { Argv } from "yargs"
import { App } from "../../app/app"
import { Bus } from "../../bus"
import { Provider } from "../../provider/provider"
import { Session } from "../../session"
import { Share } from "../../share/share"
import { Message } from "../../session/message"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
import { MessageV2 } from "../../session/message-v2"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -55,111 +54,109 @@ export const RunCommand = cmd({
})
},
handler: async (args) => {
const message = args.message.join(" ")
await App.provide(
{
cwd: process.cwd(),
},
async () => {
await Share.init()
const session = await (async () => {
if (args.continue) {
const first = await Session.list().next()
if (first.done) return
return first.value
}
let message = args.message.join(" ")
if (args.session) return Session.get(args.session)
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
await bootstrap({ cwd: process.cwd() }, async () => {
const session = await (async () => {
if (args.continue) {
const first = await Session.list().next()
if (first.done) return
return first.value
}
UI.empty()
UI.println(UI.logo())
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
if (args.session) return Session.get(args.session)
const cfg = await Config.get()
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
await Session.share(session.id)
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://opencode.ai/s/" +
session.id.slice(-8),
)
}
UI.empty()
return Session.create()
})()
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
if (!session) {
UI.error("Session not found")
return
}
const isPiped = !process.stdout.isTTY
UI.empty()
UI.println(UI.logo())
UI.empty()
const displayMessage = message.length > 300 ? message.slice(0, 300) + "..." : message
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", displayMessage)
UI.empty()
const cfg = await Config.get()
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
await Session.share(session.id)
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + session.id.slice(-8))
}
UI.empty()
const { providerID, modelID } = args.model ? Provider.parseModel(args.model) : await Provider.defaultModel()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "@ ", UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`)
UI.empty()
function printEvent(color: string, type: string, title: string) {
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
color + `|`,
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
UI.empty()
}
function printEvent(color: string, type: string, title: string) {
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL +
UI.Style.TEXT_DIM +
` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
if (evt.properties.sessionID !== session.id) return
const part = evt.properties.part
if (part.type === "tool" && part.state.status === "completed") {
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
printEvent(color, tool, part.state.title || "Unknown")
}
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
if (evt.properties.sessionID !== session.id) return
const part = evt.properties.part
const message = await Session.getMessage(
evt.properties.sessionID,
evt.properties.messageID,
)
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
const metadata =
message.metadata.tool[part.toolInvocation.toolCallId]
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD,
]
printEvent(color, tool, metadata.title)
if (part.type === "text") {
if (part.text.includes("\n")) {
UI.empty()
UI.println(part.text)
UI.empty()
return
}
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
}
})
if (part.type === "text") {
if (part.text.includes("\n")) {
UI.empty()
UI.println(part.text)
UI.empty()
return
}
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
}
})
await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
UI.empty()
},
)
let errorMsg: string | undefined
Bus.subscribe(Session.Event.Error, async (evt) => {
const { sessionID, error } = evt.properties
if (sessionID !== session.id || !error) return
let err = String(error.name)
if ("data" in error && error.data && "message" in error.data) {
err = error.data.message
}
errorMsg = errorMsg ? errorMsg + "\n" + err : err
UI.error(err)
})
const result = await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
if (isPiped) {
const match = result.parts.findLast((x) => x.type === "text")
if (match) process.stdout.write(match.text)
if (errorMsg) process.stdout.write(errorMsg)
}
UI.empty()
})
},
})
-18
View File
@@ -1,18 +0,0 @@
import { App } from "../../app/app"
import { LSP } from "../../lsp"
import { cmd } from "./cmd"
export const ScrapCommand = cmd({
command: "scrap <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await App.provide(
{ cwd: process.cwd() },
async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
},
)
},
})
+3 -5
View File
@@ -1,7 +1,7 @@
import { App } from "../../app/app"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { Share } from "../../share/share"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
export const ServeCommand = cmd({
@@ -23,7 +23,7 @@ export const ServeCommand = cmd({
describe: "starts a headless opencode server",
handler: async (args) => {
const cwd = process.cwd()
await App.provide({ cwd }, async () => {
await bootstrap({ cwd }, async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
@@ -38,9 +38,7 @@ export const ServeCommand = cmd({
hostname,
})
console.log(
`opencode server listening on http://${server.hostname}:${server.port}`,
)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})
+156
View File
@@ -0,0 +1,156 @@
import { Global } from "../../global"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { cmd } from "./cmd"
import path from "path"
import fs from "fs/promises"
import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
import { Log } from "../../util/log"
import { FileWatcher } from "../../file/watch"
export const TuiCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("prompt", {
alias: ["p"],
type: "string",
describe: "prompt to use",
}),
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
const result = await bootstrap({ cwd }, async (app) => {
FileWatcher.init()
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
const server = Server.listen({
port: 0,
hostname: "127.0.0.1",
})
let cmd = ["go", "run", "./main.go"]
let cwd = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
let binaryName = blob.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
}
cwd = process.cwd()
cmd = [binary]
}
Log.Default.info("tui", {
cmd,
})
const proc = Bun.spawn({
cmd: [
...cmd,
...(args.model ? ["--model", args.model] : []),
...(args.prompt ? ["--prompt", args.prompt] : []),
],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
;(async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest().catch(() => {})
if (!latest) return
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => {})
})()
await proc.exited
server.stop()
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
UI.println(UI.logo(" "))
const result = await Bun.spawn({
cmd: [...getOpencodeCommand(), "auth", "login"],
cwd: process.cwd(),
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
}).exited
if (result !== 0) return
UI.empty()
}
}
},
})
/**
* Get the correct command to run opencode CLI
* In development: ["bun", "run", "packages/opencode/src/index.ts"]
* In production: ["/path/to/opencode"]
*/
function getOpencodeCommand(): string[] {
// Check if OPENCODE_BIN_PATH is set (used by shell wrapper scripts)
if (process.env["OPENCODE_BIN_PATH"]) {
return [process.env["OPENCODE_BIN_PATH"]]
}
const execPath = process.execPath.toLowerCase()
if (Installation.isDev()) {
// In development, use bun to run the TypeScript entry point
return [execPath, "run", process.argv[1]]
}
// In production, use the current executable path
return [process.execPath]
}
+9 -5
View File
@@ -27,22 +27,26 @@ export const UpgradeCommand = {
const detectedMethod = await Installation.method()
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
prompts.log.error(
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
)
prompts.log.error(`opencode is installed to ${process.execPath} and seems to be managed by a package manager`)
prompts.outro("Done")
return
}
prompts.log.info("Using method: " + method)
const target = args.target ?? (await Installation.latest())
if (Installation.VERSION === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
prompts.outro("Done")
return
}
prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed")
if (err instanceof Installation.UpgradeFailedError)
prompts.log.error(err.data.stderr)
if (err instanceof Installation.UpgradeFailedError) prompts.log.error(err.data.stderr)
else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")
return
+2 -5
View File
@@ -5,14 +5,11 @@ import { UI } from "./ui"
export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (Config.JsonError.isInstance(input))
return `Config file at ${input.data.path} is not valid JSON`
if (Config.JsonError.isInstance(input)) return `Config file at ${input.data.path} is not valid JSON`
if (Config.InvalidError.isInstance(input))
return [
`Config file at ${input.data.path} is invalid`,
...(input.data.issues?.map(
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []),
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
].join("\n")
if (UI.CancelledError.isInstance(input)) return ""
+76 -101
View File
@@ -22,34 +22,34 @@ export namespace Config {
}
}
log.info("loaded", result)
return result
})
export const McpLocal = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z
.string()
.array()
.describe("Command and arguments to run the MCP server"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
ref: "Config.McpLocal",
ref: "McpLocalConfig",
})
export const McpRemote = z
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
ref: "Config.McpRemote",
ref: "McpRemoteConfig",
})
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
@@ -57,103 +57,57 @@ export namespace Config {
export const Keybinds = z
.object({
leader: z
.string()
.optional()
.describe("Leader key for keybind combinations"),
help: z.string().optional().describe("Show help dialog"),
editor_open: z.string().optional().describe("Open external editor"),
session_new: z.string().optional().describe("Create a new session"),
session_list: z.string().optional().describe("List all sessions"),
session_share: z.string().optional().describe("Share current session"),
session_interrupt: z
.string()
.optional()
.describe("Interrupt current session"),
session_compact: z
.string()
.optional()
.describe("Toggle compact mode for session"),
tool_details: z.string().optional().describe("Show tool details"),
model_list: z.string().optional().describe("List available models"),
theme_list: z.string().optional().describe("List available themes"),
project_init: z
.string()
.optional()
.describe("Initialize project configuration"),
input_clear: z.string().optional().describe("Clear input field"),
input_paste: z.string().optional().describe("Paste from clipboard"),
input_submit: z.string().optional().describe("Submit input"),
input_newline: z.string().optional().describe("Insert newline in input"),
history_previous: z
.string()
.optional()
.describe("Navigate to previous history item"),
history_next: z
.string()
.optional()
.describe("Navigate to next history item"),
messages_page_up: z
.string()
.optional()
.describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.describe("Scroll messages down by one page"),
messages_half_page_up: z
.string()
.optional()
.describe("Scroll messages up by half page"),
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_share: z.string().optional().default("<leader>s").describe("Share current session"),
session_unshare: z.string().optional().default("<leader>u").describe("Unshare current session"),
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
model_list: z.string().optional().default("<leader>m").describe("List available models"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
file_list: z.string().optional().default("<leader>f").describe("List files"),
file_close: z.string().optional().default("esc").describe("Close file"),
file_search: z.string().optional().default("<leader>/").describe("Search file"),
file_diff_toggle: z.string().optional().default("<leader>v").describe("Split/unified diff"),
project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("enter").describe("Submit input"),
input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"),
messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"),
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
.optional()
.default("ctrl+alt+d")
.describe("Scroll messages down by half page"),
messages_previous: z
.string()
.optional()
.describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
messages_first: z
.string()
.optional()
.describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
app_exit: z.string().optional().describe("Exit the application"),
messages_previous: z.string().optional().default("ctrl+up").describe("Navigate to previous message"),
messages_next: z.string().optional().default("ctrl+down").describe("Navigate to next message"),
messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
messages_layout_toggle: z.string().optional().default("<leader>p").describe("Toggle layout"),
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_revert: z.string().optional().default("<leader>r").describe("Revert message"),
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
})
.strict()
.openapi({
ref: "Config.Keybinds",
ref: "KeybindsConfig",
})
export const Info = z
.object({
$schema: z
.string()
.optional()
.describe("JSON schema reference for configuration validation"),
theme: z
.string()
.optional()
.describe("Theme name to use for the interface"),
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
autoshare: z
.boolean()
.optional()
.describe("Share newly created sessions automatically"),
autoupdate: z
.boolean()
.optional()
.describe("Automatically update to the latest version"),
disabled_providers: z
.array(z.string())
.optional()
.describe("Disable providers that are loaded automatically"),
model: z
.string()
.describe(
"Model to use in the format of provider/model, eg anthropic/claude-2",
)
.optional(),
autoshare: z.boolean().optional().describe("Share newly created sessions automatically"),
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
provider: z
.record(
ModelsDev.Provider.partial().extend({
@@ -163,14 +117,38 @@ export namespace Config {
)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z
.record(z.string(), Mcp)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
experimental: z
.object({
hook: z
.object({
file_edited: z
.record(
z.string(),
z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array(),
)
.optional(),
session_completed: z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array()
.optional(),
})
.optional(),
})
.optional(),
})
.strict()
.openapi({
ref: "Config.Info",
ref: "Config",
})
export type Info = z.output<typeof Info>
@@ -188,10 +166,7 @@ export namespace Config {
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await Bun.write(
path.join(Global.Path.config, "config.json"),
JSON.stringify(result, null, 2),
)
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})
+52
View File
@@ -0,0 +1,52 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { Session } from "../session"
import { Log } from "../util/log"
import { Config } from "./config"
import path from "path"
export namespace ConfigHooks {
const log = Log.create({ service: "config.hooks" })
export function init() {
log.info("init")
const app = App.info()
Bus.subscribe(File.Event.Edited, async (payload) => {
const cfg = await Config.get()
const ext = path.extname(payload.properties.file)
for (const item of cfg.experimental?.hook?.file_edited?.[ext] ?? []) {
log.info("file_edited", {
file: payload.properties.file,
command: item.command,
})
Bun.spawn({
cmd: item.command.map((x) => x.replace("$FILE", payload.properties.file)),
env: item.environment,
cwd: app.path.cwd,
stdout: "ignore",
stderr: "ignore",
})
}
})
Bus.subscribe(Session.Event.Idle, async () => {
const cfg = await Config.get()
if (cfg.experimental?.hook?.session_completed) {
for (const item of cfg.experimental.hook.session_completed) {
log.info("session_completed", {
command: item.command,
})
Bun.spawn({
cmd: item.command,
cwd: App.info().path.cwd,
env: item.environment,
stdout: "ignore",
stderr: "ignore",
})
}
}
})
}
}
-132
View File
@@ -1,132 +0,0 @@
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import { z } from "zod"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Fzf } from "./fzf"
export namespace Ripgrep {
const PLATFORM = {
darwin: { platform: "apple-darwin", extension: "tar.gz" },
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
win32: { platform: "pc-windows-msvc", extension: "zip" },
} as const
export const ExtractionFailedError = NamedError.create(
"RipgrepExtractionFailedError",
z.object({
filepath: z.string(),
stderr: z.string(),
}),
)
export const UnsupportedPlatformError = NamedError.create(
"RipgrepUnsupportedPlatformError",
z.object({
platform: z.string(),
}),
)
export const DownloadFailedError = NamedError.create(
"RipgrepDownloadFailedError",
z.object({
url: z.string(),
status: z.number(),
}),
)
const state = lazy(async () => {
let filepath = Bun.which("rg")
if (filepath) return { filepath }
filepath = path.join(
Global.Path.bin,
"rg" + (process.platform === "win32" ? ".exe" : ""),
)
const file = Bun.file(filepath)
if (!(await file.exists())) {
const archMap = { x64: "x86_64", arm64: "aarch64" } as const
const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
const version = "14.1.1"
const filename = `ripgrep-${version}-${arch}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
await Bun.write(archivePath, buffer)
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
if (process.platform === "darwin") args.push("--include=*/rg")
if (process.platform === "linux") args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "pipe",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath: archivePath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
await fs.unlink(archivePath)
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
}
return {
filepath,
}
})
export async function filepath() {
const { filepath } = await state()
return filepath
}
export async function files(input: {
cwd: string
query?: string
glob?: string
limit?: number
}) {
const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
if (input.query)
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
return result.split("\n").filter(Boolean)
}
}
@@ -5,7 +5,6 @@ import { z } from "zod"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { Log } from "../util/log"
import { $ } from "bun"
export namespace Fzf {
const log = Log.create({ service: "fzf" })
@@ -46,10 +45,7 @@ export namespace Fzf {
log.info("found", { filepath })
return { filepath }
}
filepath = path.join(
Global.Path.bin,
"fzf" + (process.platform === "win32" ? ".exe" : ""),
)
filepath = path.join(Global.Path.bin, "fzf" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath)
if (!(await file.exists())) {
@@ -57,18 +53,15 @@ export namespace Fzf {
const arch = archMap[process.arch as keyof typeof archMap] ?? "amd64"
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
if (!config) throw new UnsupportedPlatformError({ platform: process.platform })
const version = VERSION
const platformName =
process.platform === "win32" ? "windows" : process.platform
const platformName = process.platform === "win32" ? "windows" : process.platform
const filename = `fzf-${version}-${platformName}_${arch}.${config.extension}`
const url = `https://github.com/junegunn/fzf/releases/download/v${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
@@ -87,14 +80,11 @@ export namespace Fzf {
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
const proc = Bun.spawn(["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin], {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
@@ -115,24 +105,4 @@ export namespace Fzf {
const { filepath } = await state()
return filepath
}
export async function search(input: {
cwd: string
query: string
limit?: number
}) {
const results = await $`${await filepath()} --filter=${input.query}`
.quiet()
.throws(false)
.cwd(input.cwd)
.text()
const split = results
.trim()
.split("\n")
.filter((line) => line.length > 0)
log.info("results", {
count: split.length,
})
return split
}
}
+110
View File
@@ -0,0 +1,110 @@
import { z } from "zod"
import { Bus } from "../bus"
import { $ } from "bun"
import { createPatch } from "diff"
import path from "path"
import * as git from "isomorphic-git"
import { App } from "../app/app"
import fs from "fs"
import { Log } from "../util/log"
export namespace File {
const log = Log.create({ service: "file" })
export const Event = {
Edited: Bus.event(
"file.edited",
z.object({
file: z.string(),
}),
),
}
export async function status() {
const app = App.info()
if (!app.git) return []
const diffOutput = await $`git diff --numstat HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
const changedFiles = []
if (diffOutput.trim()) {
const lines = diffOutput.trim().split("\n")
for (const line of lines) {
const [added, removed, filepath] = line.split("\t")
changedFiles.push({
file: filepath,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = await $`git ls-files --others --exclude-standard`.cwd(app.path.cwd).quiet().nothrow().text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Bun.file(path.join(app.path.root, filepath)).text()
const lines = content.split("\n").length
changedFiles.push({
file: filepath,
added: lines,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
// Get deleted files
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
for (const filepath of deletedFiles) {
changedFiles.push({
file: filepath,
added: 0,
removed: 0, // Could get original line count but would require another git command
status: "deleted",
})
}
}
return changedFiles.map((x) => ({
...x,
file: path.relative(app.path.cwd, path.join(app.path.root, x.file)),
}))
}
export async function read(file: string) {
using _ = log.time("read", { file })
const app = App.info()
const full = path.join(app.path.cwd, file)
const content = await Bun.file(full)
.text()
.catch(() => "")
.then((x) => x.trim())
if (app.git) {
const rel = path.relative(app.path.root, full)
const diff = await git.status({
fs,
dir: app.path.root,
filepath: rel,
})
if (diff !== "unmodified") {
const original = await $`git show HEAD:${rel}`.cwd(app.path.root).quiet().nothrow().text()
const patch = createPatch(file, original, content, "old", "new", {
context: Infinity,
})
return { type: "patch", content: patch }
}
}
return { type: "raw", content }
}
}
+329
View File
@@ -0,0 +1,329 @@
// Ripgrep utility functions
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import { z } from "zod"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Fzf } from "./fzf"
export namespace Ripgrep {
const Stats = z.object({
elapsed: z.object({
secs: z.number(),
nanos: z.number(),
human: z.string(),
}),
searches: z.number(),
searches_with_match: z.number(),
bytes_searched: z.number(),
bytes_printed: z.number(),
matched_lines: z.number(),
matches: z.number(),
})
const Begin = z.object({
type: z.literal("begin"),
data: z.object({
path: z.object({
text: z.string(),
}),
}),
})
export const Match = z.object({
type: z.literal("match"),
data: z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
}),
),
}),
})
const End = z.object({
type: z.literal("end"),
data: z.object({
path: z.object({
text: z.string(),
}),
binary_offset: z.number().nullable(),
stats: Stats,
}),
})
const Summary = z.object({
type: z.literal("summary"),
data: z.object({
elapsed_total: z.object({
human: z.string(),
nanos: z.number(),
secs: z.number(),
}),
stats: Stats,
}),
})
const Result = z.union([Begin, Match, End, Summary])
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": {
platform: "aarch64-unknown-linux-gnu",
extension: "tar.gz",
},
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
export const ExtractionFailedError = NamedError.create(
"RipgrepExtractionFailedError",
z.object({
filepath: z.string(),
stderr: z.string(),
}),
)
export const UnsupportedPlatformError = NamedError.create(
"RipgrepUnsupportedPlatformError",
z.object({
platform: z.string(),
}),
)
export const DownloadFailedError = NamedError.create(
"RipgrepDownloadFailedError",
z.object({
url: z.string(),
status: z.number(),
}),
)
const state = lazy(async () => {
let filepath = Bun.which("rg")
if (filepath) return { filepath }
filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath)
if (!(await file.exists())) {
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
const version = "14.1.1"
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
await Bun.write(archivePath, buffer)
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "pipe",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin], {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath: archivePath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
await fs.unlink(archivePath)
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
}
return {
filepath,
}
})
export async function filepath() {
const { filepath } = await state()
return filepath
}
export async function files(input: { cwd: string; query?: string; glob?: string; limit?: number }) {
const commands = [
`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
return result.split("\n").filter(Boolean)
}
export async function tree(input: { cwd: string; limit?: number }) {
const files = await Ripgrep.files({ cwd: input.cwd })
interface Node {
path: string[]
children: Node[]
}
function getPath(node: Node, parts: string[], create: boolean) {
if (parts.length === 0) return node
let current = node
for (const part of parts) {
let existing = current.children.find((x) => x.path.at(-1) === part)
if (!existing) {
if (!create) return
existing = {
path: current.path.concat(part),
children: [],
}
current.children.push(existing)
}
current = existing
}
return current
}
const root: Node = {
path: [],
children: [],
}
for (const file of files) {
const parts = file.split(path.sep)
getPath(root, parts, true)
}
function sort(node: Node) {
node.children.sort((a, b) => {
if (!a.children.length && b.children.length) return 1
if (!b.children.length && a.children.length) return -1
return a.path.at(-1)!.localeCompare(b.path.at(-1)!)
})
for (const child of node.children) {
sort(child)
}
}
sort(root)
let current = [root]
const result: Node = {
path: [],
children: [],
}
let processed = 0
const limit = input.limit ?? 50
while (current.length > 0) {
const next = []
for (const node of current) {
if (node.children.length) next.push(...node.children)
}
const max = Math.max(...current.map((x) => x.children.length))
for (let i = 0; i < max && processed < limit; i++) {
for (const node of current) {
const child = node.children[i]
if (!child) continue
getPath(result, child.path, true)
processed++
if (processed >= limit) break
}
}
if (processed >= limit) {
for (const node of [...current, ...next]) {
const compare = getPath(result, node.path, false)
if (!compare) continue
if (compare?.children.length !== node.children.length) {
const diff = node.children.length - compare.children.length
compare.children.push({
path: compare.path.concat(`[${diff} truncated]`),
children: [],
})
}
}
break
}
current = next
}
const lines: string[] = []
function render(node: Node, depth: number) {
const indent = "\t".repeat(depth)
lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : ""))
for (const child of node.children) {
render(child, depth + 1)
}
}
result.children.map((x) => render(x, 0))
return lines.join("\n")
}
export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push(input.pattern)
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
return []
}
const lines = result.text().trim().split("\n").filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((r) => r.type === "match")
.map((r) => r.data)
}
}
@@ -1,6 +1,8 @@
import { App } from "../../app/app"
import { App } from "../app/app"
import { Log } from "../util/log"
export namespace FileTimes {
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export const state = App.state("tool.filetimes", () => {
const read: {
[sessionID: string]: {
@@ -13,6 +15,7 @@ export namespace FileTimes {
})
export function read(sessionID: string, file: string) {
log.info("read", { sessionID, file })
const { read } = state()
read[sessionID] = read[sessionID] || {}
read[sessionID][file] = new Date()
@@ -24,10 +27,7 @@ export namespace FileTimes {
export async function assert(sessionID: string, filepath: string) {
const time = get(sessionID, filepath)
if (!time)
throw new Error(
`You must read the file ${filepath} before overwriting it. Use the Read tool first`,
)
if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`)
const stats = await Bun.file(filepath).stat()
if (stats.mtime.getTime() > time.getTime()) {
throw new Error(
+52
View File
@@ -0,0 +1,52 @@
import { z } from "zod"
import { Bus } from "../bus"
import fs from "fs"
import { App } from "../app/app"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
export const Event = {
Updated: Bus.event(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("rename"), z.literal("change")]),
}),
),
}
const state = App.state(
"file.watcher",
() => {
const app = App.use()
if (!app.info.git) return {}
try {
const watcher = fs.watch(app.info.path.cwd, { recursive: true }, (event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
})
})
return { watcher }
} catch {
return {}
}
},
async (state) => {
state.watcher?.close()
},
)
export function init() {
if (Flag.OPENCODE_DISABLE_WATCHER) return
state()
}
}
+1
View File
@@ -1,5 +1,6 @@
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_DISABLE_WATCHER = truthy("OPENCODE_DISABLE_WATCHER")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
+146
View File
@@ -0,0 +1,146 @@
import { App } from "../app/app"
import { BunProc } from "../bun"
export interface Info {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
}
export const gofmt: Info = {
name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return Bun.which("gofmt") !== null
},
}
export const mix: Info = {
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return Bun.which("mix") !== null
},
}
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
],
async enabled() {
// this is more complicated because we only want to use prettier if it's
// being used with the current project
try {
const proc = Bun.spawn({
cmd: [BunProc.which(), "run", "prettier", "--version"],
cwd: App.info().path.cwd,
env: {
BUN_BE_BUN: "1",
},
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
}
},
}
export const zig: Info = {
name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return Bun.which("zig") !== null
},
}
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
return Bun.which("clang-format") !== null
},
}
export const ktlint: Info = {
name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return Bun.which("ktlint") !== null
},
}
export const ruff: Info = {
name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
return Bun.which("ruff") !== null
},
}
export const rubocop: Info = {
name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("rubocop") !== null
},
}
export const standardrb: Info = {
name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("standardrb") !== null
},
}
export const htmlbeautifier: Info = {
name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return Bun.which("htmlbeautifier") !== null
},
}
+65
View File
@@ -0,0 +1,65 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
import * as Formatter from "./formatter"
export namespace Format {
const log = Log.create({ service: "format" })
const state = App.state("format", () => {
const enabled: Record<string, boolean> = {}
return {
enabled,
}
})
async function isEnabled(item: Formatter.Info) {
const s = state()
let status = s.enabled[item.name]
if (status === undefined) {
status = await item.enabled()
s.enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const result = []
for (const item of Object.values(Formatter)) {
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
result.push(item)
}
return result
}
export function init() {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
const proc = Bun.spawn({
cmd: item.command.map((x) => x.replace("$FILE", file)),
cwd: App.info().path.cwd,
env: item.environment,
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
...item.environment,
})
}
})
}
}
+4 -17
View File
@@ -26,11 +26,7 @@ export namespace Identifier {
return generateID(prefix, true, given)
}
function generateID(
prefix: keyof typeof prefixes,
descending: boolean,
given?: string,
): string {
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
if (!given) {
return generateNewID(prefix, descending)
}
@@ -42,8 +38,7 @@ export namespace Identifier {
}
function randomBase62(length: number): string {
const chars =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
@@ -52,10 +47,7 @@ export namespace Identifier {
return result
}
function generateNewID(
prefix: keyof typeof prefixes,
descending: boolean,
): string {
function generateNewID(prefix: keyof typeof prefixes, descending: boolean): string {
const currentTimestamp = Date.now()
if (currentTimestamp !== lastTimestamp) {
@@ -73,11 +65,6 @@ export namespace Identifier {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return (
prefixes[prefix] +
"_" +
timeBytes.toString("hex") +
randomBase62(LENGTH - 12)
)
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
}
+34 -114
View File
@@ -1,31 +1,34 @@
import "zod-openapi/extend"
import { App } from "./app/app"
import { Server } from "./server/server"
import fs from "fs/promises"
import path from "path"
import { Share } from "./share/share"
import url from "node:url"
import { Global } from "./global"
import yargs from "yargs"
import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { AuthCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { ModelsCommand } from "./cli/cmd/models"
import { Provider } from "./provider/provider"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { Bus } from "./bus"
import { Config } from "./config/config"
import { NamedError } from "./util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { TuiCommand } from "./cli/cmd/tui"
import { DebugCommand } from "./cli/cmd/debug"
const cancel = new AbortController()
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: e instanceof Error ? e.message : e,
})
})
process.on("uncaughtException", (e) => {
Log.Default.error("exception", {
e: e instanceof Error ? e.message : e,
})
})
const cli = yargs(hideBin(process.argv))
.scriptName("opencode")
.help("help", "show help")
@@ -43,110 +46,16 @@ const cli = yargs(hideBin(process.argv))
})
})
.usage("\n" + UI.logo())
.command({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
describe: "path to start opencode in",
}),
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
process.chdir(cwd)
const result = await App.provide({ cwd }, async (app) => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
await Share.init()
const server = Server.listen({
port: 0,
hostname: "127.0.0.1",
})
let cmd = ["go", "run", "./main.go"]
let cwd = url.fileURLToPath(
new URL("../../tui/cmd/opencode", import.meta.url),
)
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
let binaryName = blob.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
}
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
signal: cancel.signal,
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
;(async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest().catch(() => {})
if (!latest) return
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => {})
})()
await proc.exited
server.stop()
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
await AuthLoginCommand.handler(args)
}
}
},
})
.command(TuiCommand)
.command(RunCommand)
.command(GenerateCommand)
.command(ScrapCommand)
.command(DebugCommand)
.command(AuthCommand)
.command(UpgradeCommand)
.command(ServeCommand)
.command(ModelsCommand)
.fail((msg) => {
if (
msg.startsWith("Unknown argument") ||
msg.startsWith("Not enough non-option arguments")
) {
if (msg.startsWith("Unknown argument") || msg.startsWith("Not enough non-option arguments")) {
cli.showHelp("log")
}
})
@@ -155,13 +64,14 @@ const cli = yargs(hideBin(process.argv))
try {
await cli.parse()
} catch (e) {
const data: Record<string, any> = {}
let data: Record<string, any> = {}
if (e instanceof NamedError) {
const obj = e.toObject()
Object.assign(data, {
...obj.data,
})
}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
@@ -169,13 +79,23 @@ try {
cause: e.cause?.toString(),
})
}
if (e instanceof ResolveMessage) {
Object.assign(data, {
name: e.name,
message: e.message,
code: e.code,
specifier: e.specifier,
referrer: e.referrer,
position: e.position,
importKind: e.importKind,
})
}
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (formatted === undefined)
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
if (formatted === undefined) UI.error("Unexpected error, check log file at " + Log.file() + " for more details")
process.exitCode = 1
}
cancel.abort()
+1 -2
View File
@@ -135,8 +135,7 @@ export namespace Installation {
})
}
export const VERSION =
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export async function latest() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
+44 -56
View File
@@ -1,9 +1,5 @@
import path from "path"
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import { App } from "../app/app"
import { Log } from "../util/log"
@@ -12,6 +8,7 @@ import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "../util/error"
import { withTimeout } from "../util/timeout"
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
@@ -52,7 +49,9 @@ export namespace LSPClient {
log.info("textDocument/publishDiagnostics", {
path,
})
const exists = diagnostics.has(path)
diagnostics.set(path, params.diagnostics)
if (!exists && serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID })
})
connection.onRequest("workspace/configuration", async () => {
@@ -61,8 +60,9 @@ export namespace LSPClient {
connection.listen()
log.info("sending initialize", { id: serverID })
await Promise.race([
await withTimeout(
connection.sendRequest("initialize", {
rootUri: "file://" + app.path.cwd,
processId: server.process.pid,
workspaceFolders: [
{
@@ -88,14 +88,21 @@ export namespace LSPClient {
},
},
}),
new Promise((_, reject) => {
setTimeout(() => {
reject(new InitializeError({ serverID }))
}, 5_000)
}),
])
5_000,
).catch((err) => {
log.error("initialize error", { error: err })
throw new InitializeError(
{ serverID },
{
cause: err,
},
)
})
await connection.sendNotification("initialized", {})
log.info("initialized")
log.info("initialized", {
serverID,
})
const files: {
[path: string]: number
@@ -110,82 +117,63 @@ export namespace LSPClient {
},
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path)
? input.path
: path.resolve(app.path.cwd, input.path)
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
const file = Bun.file(input.path)
const text = await file.text()
const version = files[input.path]
if (version === undefined) {
log.info("textDocument/didOpen", input)
if (version !== undefined) {
diagnostics.delete(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
await connection.sendNotification("textDocument/didClose", {
textDocument: {
uri: `file://` + input.path,
languageId,
version: 0,
text,
},
})
files[input.path] = 0
return
}
log.info("textDocument/didChange", input)
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didChange", {
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: `file://` + input.path,
version: ++files[input.path],
languageId,
version: 0,
text,
},
contentChanges: [
{
text,
},
],
})
files[input.path] = 0
return
},
},
get diagnostics() {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
input.path = path.isAbsolute(input.path)
? input.path
: path.resolve(app.path.cwd, input.path)
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
log.info("waiting for diagnostics", input)
let unsub: () => void
let timeout: NodeJS.Timeout
return await Promise.race([
new Promise<void>(async (resolve) => {
return await withTimeout(
new Promise<void>((resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.serverID
) {
if (event.properties.path === input.path && event.properties.serverID === result.serverID) {
log.info("got diagnostics", input)
clearTimeout(timeout)
unsub?.()
resolve()
}
})
}),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
log.info("timed out refreshing diagnostics", input)
unsub?.()
resolve()
}, 5000)
}),
])
3000,
)
.catch(() => {})
.finally(() => {
unsub?.()
})
},
async shutdown() {
log.info("shutting down")
log.info("shutting down", { serverID })
connection.end()
connection.dispose()
server.process.kill("SIGKILL")
log.info("shutdown", { serverID })
},
}
+65 -38
View File
@@ -3,19 +3,58 @@ import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
import { LSPServer } from "./server"
import { Ripgrep } from "../file/ripgrep"
import { z } from "zod"
export namespace LSP {
const log = Log.create({ service: "lsp" })
export const Symbol = z
.object({
name: z.string(),
kind: z.number(),
location: z.object({
uri: z.string(),
range: z.object({
start: z.object({
line: z.number(),
character: z.number(),
}),
end: z.object({
line: z.number(),
character: z.number(),
}),
}),
}),
})
.openapi({
ref: "LSP.Symbol",
})
export type Symbol = z.infer<typeof Symbol>
const state = App.state(
"lsp",
async () => {
async (app) => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
const skip = new Set<string>()
for (const server of Object.values(LSPServer)) {
for (const extension of server.extensions) {
const [file] = await Ripgrep.files({
cwd: app.path.cwd,
glob: "*" + extension,
})
if (!file) continue
const handle = await server.spawn(App.info())
if (!handle) break
const client = await LSPClient.create(server.id, handle).catch((err) => log.error("", { error: err }))
if (!client) break
clients.set(server.id, client)
break
}
}
log.info("initialized")
return {
clients,
skip,
}
},
async (state) => {
@@ -25,35 +64,21 @@ export namespace LSP {
},
)
export async function init() {
return state()
}
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
const extension = path.parse(input).ext
const s = await state()
const matches = LSPServer.All.filter((x) =>
x.extensions.includes(extension),
)
for (const match of matches) {
if (s.skip.has(match.id)) continue
const existing = s.clients.get(match.id)
if (existing) continue
const handle = await match.spawn(App.info())
if (!handle) {
s.skip.add(match.id)
continue
}
const client = await LSPClient.create(match.id, handle).catch(() => {})
if (!client) {
s.skip.add(match.id)
continue
}
s.clients.set(match.id, client)
}
if (waitForDiagnostics) {
await run(async (client) => {
const wait = client.waitForDiagnostics({ path: input })
await client.notify.open({ path: input })
return wait
})
}
const matches = Object.values(LSPServer)
.filter((x) => x.extensions.includes(extension))
.map((x) => x.id)
await run(async (client) => {
if (!matches.includes(client.serverID)) return
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
return wait
})
}
export async function diagnostics() {
@@ -68,11 +93,7 @@ export namespace LSP {
return results
}
export async function hover(input: {
file: string
line: number
character: number
}) {
export async function hover(input: { file: string; line: number; character: number }) {
return run((client) => {
return client.connection.sendRequest("textDocument/hover", {
textDocument: {
@@ -86,9 +107,15 @@ export namespace LSP {
})
}
async function run<T>(
input: (client: LSPClient.Info) => Promise<T>,
): Promise<T[]> {
export async function workspaceSymbol(query: string) {
return run((client) =>
client.connection.sendRequest("workspace/symbol", {
query,
}),
).then((result) => result.flat() as LSP.Symbol[])
}
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
const clients = await state().then((x) => [...x.clients.values()])
const tasks = clients.map((x) => input(x))
return Promise.all(tasks)
+10
View File
@@ -63,6 +63,14 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".cshtml": "razor",
".razor": "razor",
".rb": "ruby",
".rake": "ruby",
".gemspec": "ruby",
".ru": "ruby",
".erb": "erb",
".html.erb": "erb",
".js.erb": "erb",
".css.erb": "erb",
".json.erb": "erb",
".rs": "rust",
".scss": "scss",
".sass": "sass",
@@ -86,4 +94,6 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".yml": "yaml",
".mjs": "javascript",
".cjs": "javascript",
".zig": "zig",
".zon": "zig",
} as const
+256 -66
View File
@@ -4,6 +4,8 @@ import path from "path"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -19,78 +21,266 @@ export namespace LSPServer {
spawn(app: App.Info): Promise<Handle | undefined>
}
export const All: Info[] = [
{
id: "typescript",
extensions: [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".mts",
".cts",
],
async spawn(app) {
const tsserver = await Bun.resolve(
"typescript/lib/tsserver.js",
app.path.cwd,
).catch(() => {})
if (!tsserver) return
const proc = spawn(
BunProc.which(),
["x", "typescript-language-server", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
export const Typescript: Info = {
id: "typescript",
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(app) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
initialization: {
tsserver: {
path: tsserver,
},
)
return {
process: proc,
initialization: {
tsserver: {
path: tsserver,
},
},
}
},
},
}
},
{
id: "golang",
extensions: [".go"],
async spawn() {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
}
export const Gopls: Info = {
id: "golang",
extensions: [".go"],
async spawn() {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("go")) return
log.info("installing gopls")
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
if (!bin) {
log.info("installing gopls")
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
log.error("Failed to install gopls")
const exit = await proc.exited
if (exit !== 0) {
log.error("Failed to install gopls")
return
}
bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed gopls`, {
bin,
})
}
return {
process: spawn(bin!),
}
},
}
export const RubyLsp: Info = {
id: "ruby-lsp",
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn() {
let bin = Bun.which("ruby-lsp", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
const ruby = Bun.which("ruby")
const gem = Bun.which("gem")
if (!ruby || !gem) {
log.info("Ruby not found, please install Ruby first")
return
}
log.info("installing ruby-lsp")
const proc = Bun.spawn({
cmd: ["gem", "install", "ruby-lsp", "--bindir", Global.Path.bin],
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
log.error("Failed to install ruby-lsp")
return
}
bin = path.join(Global.Path.bin, "ruby-lsp" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed ruby-lsp`, {
bin,
})
}
return {
process: spawn(bin!, ["--stdio"]),
}
},
}
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
async spawn() {
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
}
},
}
export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
async spawn() {
let binary = Bun.which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
binary = path.join(
Global.Path.bin,
"elixir-ls-master",
"release",
process.platform === "win32" ? "language_server.bar" : "language_server.sh",
)
if (!(await Bun.file(binary).exists())) {
const elixir = Bun.which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
return
}
bin = path.join(
Global.Path.bin,
"gopls" + (process.platform === "win32" ? ".exe" : ""),
)
log.info(`installed gopls`, {
bin,
log.info("downloading elixir-ls from GitHub releases")
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
await Bun.file(zipPath).write(response)
await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
await fs.rm(zipPath, {
force: true,
recursive: true,
})
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
.quiet()
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
.env({ MIX_ENV: "prod", ...process.env })
log.info(`installed elixir-ls`, {
path: elixirLsPath,
})
}
return {
process: spawn(bin!),
}
},
}
return {
process: spawn(binary),
}
},
]
}
export const Zls: Info = {
id: "zls",
extensions: [".zig", ".zon"],
async spawn() {
let bin = Bun.which("zls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
const zig = Bun.which("zig")
if (!zig) {
log.error("Zig is required to use zls. Please install Zig first.")
return
}
log.info("downloading zls from GitHub releases")
const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
if (!releaseResponse.ok) {
log.error("Failed to fetch zls release info")
return
}
const release = await releaseResponse.json()
const platform = process.platform
const arch = process.arch
let assetName = ""
let zlsArch: string = arch
if (arch === "arm64") zlsArch = "aarch64"
else if (arch === "x64") zlsArch = "x86_64"
else if (arch === "ia32") zlsArch = "x86"
let zlsPlatform: string = platform
if (platform === "darwin") zlsPlatform = "macos"
else if (platform === "win32") zlsPlatform = "windows"
const ext = platform === "win32" ? "zip" : "tar.xz"
assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}`
const supportedCombos = [
"zls-x86_64-linux.tar.xz",
"zls-x86_64-macos.tar.xz",
"zls-x86_64-windows.zip",
"zls-aarch64-linux.tar.xz",
"zls-aarch64-macos.tar.xz",
"zls-aarch64-windows.zip",
"zls-x86-linux.tar.xz",
"zls-x86-windows.zip",
]
if (!supportedCombos.includes(assetName)) {
log.error("Unsupported platform/architecture for zls", { platform, arch, assetName })
return
}
const asset = release.assets?.find((a: any) => a.name === assetName)
if (!asset) {
log.error("Could not find zls download for platform", { platform, arch, assetName })
return
}
const downloadUrl = asset.browser_download_url
log.info("downloading zls", { url: downloadUrl })
const response = await fetch(downloadUrl)
if (!response.ok) {
log.error("Failed to download zls")
return
}
const isZip = assetName.endsWith(".zip")
const archivePath = path.join(Global.Path.bin, isZip ? "zls.zip" : "zls.tar.xz")
await Bun.file(archivePath).write(response)
if (isZip) {
await $`unzip -o -q ${archivePath} -d ${Global.Path.bin}`.nothrow()
} else {
await $`tar -xf ${archivePath} -C ${Global.Path.bin}`.quiet()
}
await fs.rm(archivePath, { force: true })
if (platform !== "win32") {
bin = path.join(Global.Path.bin, "zls")
await $`chmod +x ${bin}`.quiet()
} else {
bin = path.join(Global.Path.bin, "zls.exe")
}
log.info("installed zls", { bin })
}
return {
process: spawn(bin!),
}
},
}
}
+4
View File
@@ -26,6 +26,10 @@ export namespace MCP {
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
} = {}
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
continue
}
log.info("found", { key, type: mcp.type })
if (mcp.type === "remote") {
const client = await experimental_createMCPClient({
+4 -3
View File
@@ -10,7 +10,9 @@ export namespace ModelsDev {
export const Model = z
.object({
id: z.string(),
name: z.string(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
@@ -25,11 +27,10 @@ export namespace ModelsDev {
context: z.number(),
output: z.number(),
}),
id: z.string(),
options: z.record(z.any()),
})
.openapi({
ref: "Model.Info",
ref: "Model",
})
export type Model = z.infer<typeof Model>
@@ -43,7 +44,7 @@ export namespace ModelsDev {
models: z.record(Model),
})
.openapi({
ref: "Provider.Info",
ref: "Provider",
})
export type Provider = z.infer<typeof Provider>
+120 -56
View File
@@ -11,8 +11,6 @@ import { WebFetchTool } from "../tool/webfetch"
import { GlobTool } from "../tool/glob"
import { GrepTool } from "../tool/grep"
import { ListTool } from "../tool/ls"
import { LspDiagnosticTool } from "../tool/lsp-diagnostics"
import { LspHoverTool } from "../tool/lsp-hover"
import { PatchTool } from "../tool/patch"
import { ReadTool } from "../tool/read"
import type { Tool } from "../tool/tool"
@@ -23,25 +21,26 @@ import { AuthCopilot } from "../auth/copilot"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { TaskTool } from "../tool/task"
// import { TaskTool } from "../tool/task"
export namespace Provider {
const log = Log.create({ service: "provider" })
type CustomLoader = (provider: ModelsDev.Provider) => Promise<
| {
getModel?: (sdk: any, modelID: string) => Promise<any>
options: Record<string, any>
}
| false
>
type CustomLoader = (
provider: ModelsDev.Provider,
api?: string,
) => Promise<{
autoload: boolean
getModel?: (sdk: any, modelID: string) => Promise<any>
options?: Record<string, any>
}>
type Source = "env" | "config" | "custom" | "api"
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
async anthropic(provider) {
const access = await AuthAnthropic.access()
if (!access) return false
if (!access) return { autoload: false }
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
@@ -49,6 +48,7 @@ export namespace Provider {
}
}
return {
autoload: true,
options: {
apiKey: "",
async fetch(input: any, init: any) {
@@ -69,9 +69,9 @@ export namespace Provider {
},
"github-copilot": async (provider) => {
const copilot = await AuthCopilot()
if (!copilot) return false
if (!copilot) return { autoload: false }
let info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return false
if (!info || info.type !== "oauth") return { autoload: false }
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
@@ -83,6 +83,7 @@ export namespace Provider {
}
return {
autoload: true,
options: {
apiKey: "",
async fetch(input: any, init: any) {
@@ -90,19 +91,34 @@ export namespace Provider {
if (!info || info.type !== "oauth") return
if (!info.access || info.expires < Date.now()) {
const tokens = await copilot.access(info.refresh)
if (!tokens)
throw new Error("GitHub Copilot authentication expired")
if (!tokens) throw new Error("GitHub Copilot authentication expired")
await Auth.set("github-copilot", {
type: "oauth",
...tokens,
})
info.access = tokens.access
}
const headers = {
let isAgentCall = false
let isVisionRequest = false
try {
const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body
if (body?.messages) {
isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role))
isVisionRequest = body.messages.some(
(msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
)
}
} catch {}
const headers: Record<string, string> = {
...init.headers,
...copilot.HEADERS,
Authorization: `Bearer ${info.access}`,
"Openai-Intent": "conversation-edits",
"X-Initiator": isAgentCall ? "agent" : "user",
}
if (isVisionRequest) {
headers["Copilot-Vision-Request"] = "true"
}
delete headers["x-api-key"]
return fetch(input, {
@@ -113,29 +129,82 @@ export namespace Provider {
},
}
},
openai: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string) {
return sdk.responses(modelID)
},
options: {},
}
},
"amazon-bedrock": async () => {
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
return false
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"]) return { autoload: false }
const region = process.env["AWS_REGION"] ?? "us-east-1"
const { fromNodeProviderChain } = await import(
await BunProc.install("@aws-sdk/credential-providers")
)
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
return {
autoload: true,
options: {
region,
credentialProvider: fromNodeProviderChain(),
},
async getModel(sdk: any, modelID: string) {
if (modelID.includes("claude")) {
const prefix = region.split("-")[0]
modelID = `${prefix}.${modelID}`
let regionPrefix = region.split("-")[0]
switch (regionPrefix) {
case "us": {
const modelRequiresPrefix = ["claude", "deepseek"].some((m) => modelID.includes(m))
if (modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
break
}
case "eu": {
const regionRequiresPrefix = [
"eu-west-1",
"eu-west-3",
"eu-north-1",
"eu-central-1",
"eu-south-1",
"eu-south-2",
].some((r) => region.includes(r))
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
modelID.includes(m),
)
if (regionRequiresPrefix && modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
break
}
case "ap": {
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
modelID.includes(m),
)
if (modelRequiresPrefix) {
regionPrefix = "apac"
modelID = `${regionPrefix}.${modelID}`
}
break
}
}
return sdk.languageModel(modelID)
},
}
},
openrouter: async () => {
return {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
},
}
},
}
const state = App.state("provider", async () => {
@@ -150,10 +219,7 @@ export namespace Provider {
options: Record<string, any>
}
} = {}
const models = new Map<
string,
{ info: ModelsDev.Model; language: LanguageModel }
>()
const models = new Map<string, { info: ModelsDev.Model; language: LanguageModel }>()
const sdk = new Map<string, SDK>()
log.info("init")
@@ -173,6 +239,7 @@ export namespace Provider {
source,
info,
options,
getModel,
}
return
}
@@ -190,6 +257,7 @@ export namespace Provider {
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
models: existing?.models ?? {},
}
@@ -198,6 +266,7 @@ export namespace Provider {
const parsedModel: ModelsDev.Model = {
id: modelID,
name: model.name ?? existing?.name ?? modelID,
release_date: model.release_date ?? existing?.release_date,
attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false,
@@ -225,15 +294,18 @@ export namespace Provider {
database[providerID] = parsed
}
const disabled = await Config.get().then(
(cfg) => new Set(cfg.disabled_providers ?? []),
)
const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
// load env
for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
if (provider.env.some((item) => process.env[item])) {
mergeProvider(providerID, {}, "env")
}
const apiKey = provider.env.map((item) => process.env[item]).at(0)
if (!apiKey) continue
mergeProvider(
providerID,
// only include apiKey if there's only one potential option
provider.env.length === 1 ? { apiKey } : {},
"env",
)
}
// load apikeys
@@ -248,8 +320,8 @@ export namespace Provider {
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result) {
mergeProvider(providerID, result.options, "custom", result.getModel)
if (result && (result.autoload || providers[providerID])) {
mergeProvider(providerID, result.options ?? {}, "custom", result.getModel)
}
}
@@ -286,7 +358,7 @@ export namespace Provider {
const existing = s.sdk.get(provider.id)
if (existing) return existing
const pkg = provider.npm ?? provider.id
const mod = await import(await BunProc.install(pkg, "latest"))
const mod = await import(await BunProc.install(pkg, "beta"))
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn(s.providers[provider.id]?.options)
s.sdk.set(provider.id, loaded)
@@ -313,9 +385,7 @@ export namespace Provider {
const sdk = await getSDK(provider.info)
try {
const language = provider.getModel
? await provider.getModel(sdk, modelID)
: sdk.languageModel(modelID)
const language = provider.getModel ? await provider.getModel(sdk, modelID) : sdk.languageModel(modelID)
log.info("found", { providerID, modelID })
s.models.set(key, {
info,
@@ -342,10 +412,7 @@ export namespace Provider {
export function sort(models: ModelsDev.Model[]) {
return sortBy(
models,
[
(model) => priority.findIndex((filter) => model.id.includes(filter)),
"desc",
],
[(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
[(model) => (model.id.includes("latest") ? 0 : 1), "asc"],
[(model) => model.id, "desc"],
)
@@ -356,11 +423,7 @@ export namespace Provider {
if (cfg.model) return parseModel(cfg.model)
const provider = await list()
.then((val) => Object.values(val))
.then((x) =>
x.find(
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id),
),
)
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id)))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found")
@@ -385,16 +448,15 @@ export namespace Provider {
GlobTool,
GrepTool,
ListTool,
LspDiagnosticTool,
LspHoverTool,
// LspDiagnosticTool,
// LspHoverTool,
PatchTool,
ReadTool,
EditTool,
// MultiEditTool,
WriteTool,
TodoWriteTool,
TaskTool,
TodoReadTool,
// TaskTool,
]
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
@@ -444,9 +506,11 @@ export namespace Provider {
if (schema instanceof z.ZodUnion) {
return z.union(
schema.options.map((option: z.ZodTypeAny) =>
optionalToNullable(option),
) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
schema.options.map((option: z.ZodTypeAny) => optionalToNullable(option)) as [
z.ZodTypeAny,
z.ZodTypeAny,
...z.ZodTypeAny[],
],
)
}
+20 -8
View File
@@ -1,22 +1,34 @@
import type { LanguageModelV1Prompt } from "ai"
import type { ModelMessage } from "ai"
import { unique } from "remeda"
export namespace ProviderTransform {
export function message(
msgs: LanguageModelV1Prompt,
providerID: string,
modelID: string,
) {
export function message(msgs: ModelMessage[], providerID: string, modelID: string) {
if (providerID === "anthropic" || modelID.includes("anthropic")) {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerMetadata = {
...msg.providerMetadata,
msg.providerOptions = {
...msg.providerOptions,
anthropic: {
cacheControl: { type: "ephemeral" },
},
openaiCompatible: {
cache_control: { type: "ephemeral" },
},
}
}
}
if (providerID === "amazon-bedrock" || modelID.includes("anthropic")) {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerOptions = {
...msg.providerOptions,
bedrock: {
cachePoint: { type: "ephemeral" },
},
}
}
}
+324 -243
View File
@@ -6,16 +6,16 @@ import { streamSSE } from "hono/streaming"
import { Session } from "../session"
import { resolver, validator as zValidator } from "hono-openapi/zod"
import { z } from "zod"
import { Message } from "../session/message"
import { Provider } from "../provider/provider"
import { App } from "../app/app"
import { Global } from "../global"
import { mapValues } from "remeda"
import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep"
import { Installation } from "../installation"
import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
import { MessageV2 } from "../session/message-v2"
const ERRORS = {
400: {
@@ -51,12 +51,9 @@ export namespace Server {
status: 400,
})
}
return c.json(
new NamedError.Unknown({ message: err.toString() }).toObject(),
{
status: 400,
},
)
return c.json(new NamedError.Unknown({ message: err.toString() }).toObject(), {
status: 400,
})
})
.use(async (c, next) => {
log.info("request", {
@@ -70,12 +67,12 @@ export namespace Server {
})
})
.get(
"/openapi",
"/doc",
openAPISpecs(app, {
documentation: {
info: {
title: "opencode",
version: "1.0.0",
version: "0.0.3",
description: "opencode api",
},
openapi: "3.0.0",
@@ -122,8 +119,8 @@ export namespace Server {
})
},
)
.post(
"/app_info",
.get(
"/app",
describeRoute({
description: "Get app info",
responses: {
@@ -142,26 +139,7 @@ export namespace Server {
},
)
.post(
"/config_get",
describeRoute({
description: "Get config info",
responses: {
200: {
description: "Get config info",
content: {
"application/json": {
schema: resolver(Config.Info),
},
},
},
},
}),
async (c) => {
return c.json(await Config.get())
},
)
.post(
"/app_initialize",
"/app/init",
describeRoute({
description: "Initialize the app",
responses: {
@@ -180,172 +158,27 @@ export namespace Server {
return c.json(true)
},
)
.post(
"/session_initialize",
.get(
"/config",
describeRoute({
description: "Analyze the app and create an AGENTS.md file",
description: "Get config info",
responses: {
200: {
description: "200",
description: "Get config info",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.initialize(body)
return c.json(true)
},
)
.post(
"/path_get",
describeRoute({
description: "Get paths",
responses: {
200: {
description: "200",
content: {
"application/json": {
schema: resolver(
z.object({
root: z.string(),
data: z.string(),
cwd: z.string(),
config: z.string(),
}),
),
schema: resolver(Config.Info),
},
},
},
},
}),
async (c) => {
const app = App.info()
return c.json({
root: app.path.root,
data: app.path.data,
cwd: app.path.cwd,
config: Global.Path.data,
})
return c.json(await Config.get())
},
)
.post(
"/session_create",
describeRoute({
description: "Create a new session",
responses: {
...ERRORS,
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
async (c) => {
const session = await Session.create()
return c.json(session)
},
)
.post(
"/session_share",
describeRoute({
description: "Share the session",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.share(body.sessionID)
const session = await Session.get(body.sessionID)
return c.json(session)
},
)
.post(
"/session_unshare",
describeRoute({
description: "Unshare the session",
responses: {
200: {
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.unshare(body.sessionID)
const session = await Session.get(body.sessionID)
return c.json(session)
},
)
.post(
"/session_messages",
describeRoute({
description: "Get messages for a session",
responses: {
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const messages = await Session.messages(c.req.valid("json").sessionID)
return c.json(messages)
},
)
.post(
"/session_list",
.get(
"/session",
describeRoute({
description: "List all sessions",
responses: {
@@ -365,33 +198,28 @@ export namespace Server {
},
)
.post(
"/session_abort",
"/session",
describeRoute({
description: "Abort a session",
description: "Create a new session",
responses: {
...ERRORS,
200: {
description: "Aborted session",
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(z.boolean()),
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
return c.json(Session.abort(body.sessionID))
const session = await Session.create()
return c.json(session)
},
)
.post(
"/session_delete",
.delete(
"/session/:id",
describeRoute({
description: "Delete a session and all its data",
responses: {
@@ -406,24 +234,23 @@ export namespace Server {
},
}),
zValidator(
"json",
"param",
z.object({
sessionID: z.string(),
id: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.remove(body.sessionID)
await Session.remove(c.req.valid("param").id)
return c.json(true)
},
)
.post(
"/session_summarize",
"/session/:id/init",
describeRoute({
description: "Summarize the session",
description: "Analyze the app and create an AGENTS.md file",
responses: {
200: {
description: "Summarize the session",
description: "200",
content: {
"application/json": {
schema: resolver(z.boolean()),
@@ -432,52 +259,206 @@ export namespace Server {
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json")
await Session.summarize(body)
await Session.initialize({ ...body, sessionID })
return c.json(true)
},
)
.post(
"/session_chat",
"/session/:id/abort",
describeRoute({
description: "Chat with a model",
description: "Abort a session",
responses: {
200: {
description: "Chat with a model",
description: "Aborted session",
content: {
"application/json": {
schema: resolver(Message.Info),
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
"param",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
parts: Message.Part.array(),
id: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
const msg = await Session.chat(body)
return c.json(msg)
return c.json(Session.abort(c.req.valid("param").id))
},
)
.post(
"/provider_list",
"/session/:id/share",
describeRoute({
description: "Share a session",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
await Session.share(id)
const session = await Session.get(id)
return c.json(session)
},
)
.delete(
"/session/:id/share",
describeRoute({
description: "Unshare the session",
responses: {
200: {
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
await Session.unshare(id)
const session = await Session.get(id)
return c.json(session)
},
)
.post(
"/session/:id/summarize",
describeRoute({
description: "Summarize the session",
responses: {
200: {
description: "Summarized session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator(
"json",
z.object({
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
const body = c.req.valid("json")
await Session.summarize({ ...body, sessionID: id })
return c.json(true)
},
)
.get(
"/session/:id/message",
describeRoute({
description: "List messages for a session",
responses: {
200: {
description: "List of messages",
content: {
"application/json": {
schema: resolver(MessageV2.Info.array()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
async (c) => {
const messages = await Session.messages(c.req.valid("param").id)
return c.json(messages)
},
)
.post(
"/session/:id/message",
describeRoute({
description: "Create and send a new message to a session",
responses: {
200: {
description: "Created message",
content: {
"application/json": {
schema: resolver(MessageV2.Assistant),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator(
"json",
z.object({
providerID: z.string(),
modelID: z.string(),
parts: MessageV2.UserPart.array(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json")
const msg = await Session.chat({ ...body, sessionID })
return c.json(msg)
},
)
.get(
"/config/providers",
describeRoute({
description: "List all providers",
responses: {
@@ -497,25 +478,52 @@ export namespace Server {
},
}),
async (c) => {
const providers = await Provider.list().then((x) =>
mapValues(x, (item) => item.info),
)
const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
return c.json({
providers: Object.values(providers),
default: mapValues(
providers,
(item) => Provider.sort(Object.values(item.models))[0].id,
),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
})
},
)
.post(
"/file_search",
.get(
"/find",
describeRoute({
description: "Search for files",
description: "Find text in files",
responses: {
200: {
description: "Search for files",
description: "Matches",
content: {
"application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
pattern: z.string(),
}),
),
async (c) => {
const app = App.info()
const pattern = c.req.valid("query").pattern
const result = await Ripgrep.search({
cwd: app.path.cwd,
pattern,
limit: 10,
})
return c.json(result)
},
)
.get(
"/find/file",
describeRoute({
description: "Find files",
responses: {
200: {
description: "File paths",
content: {
"application/json": {
schema: resolver(z.string().array()),
@@ -525,39 +533,112 @@ export namespace Server {
},
}),
zValidator(
"json",
"query",
z.object({
query: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
const query = c.req.valid("query").query
const app = App.info()
const result = await Ripgrep.files({
cwd: app.path.cwd,
query: body.query,
query,
limit: 10,
})
return c.json(result)
},
)
.post(
"installation_info",
.get(
"/find/symbol",
describeRoute({
description: "Get installation info",
description: "Find workspace symbols",
responses: {
200: {
description: "Get installation info",
description: "Symbols",
content: {
"application/json": {
schema: resolver(Installation.Info),
schema: resolver(z.unknown().array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
query: z.string(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
},
)
.get(
"/file",
describeRoute({
description: "Read a file",
responses: {
200: {
description: "File content",
content: {
"application/json": {
schema: resolver(
z.object({
type: z.enum(["raw", "patch"]),
content: z.string(),
}),
),
},
},
},
},
}),
zValidator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
log.info("read file", {
path,
content: content.content,
})
return c.json(content)
},
)
.get(
"/file/status",
describeRoute({
description: "Get file status",
responses: {
200: {
description: "File status",
content: {
"application/json": {
schema: resolver(
z
.object({
file: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.array(),
),
},
},
},
},
}),
async (c) => {
return c.json(Installation.info())
const content = await File.status()
return c.json(content)
},
)
+501 -462
View File
@@ -1,39 +1,40 @@
import path from "path"
import { App } from "../app/app"
import { Identifier } from "../id/id"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
import { Decimal } from "decimal.js"
import { z, ZodSchema } from "zod"
import {
generateText,
LoadAPIKeyError,
convertToCoreMessages,
streamText,
tool,
wrapLanguageModel,
type Tool as AITool,
type LanguageModelUsage,
type CoreMessage,
type UIMessage,
type ProviderMetadata,
wrapLanguageModel,
type ModelMessage,
stepCountIs,
} from "ai"
import { z, ZodSchema } from "zod"
import { Decimal } from "decimal.js"
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
import { Share } from "../share/share"
import { Message } from "./message"
import { App } from "../app/app"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { MCP } from "../mcp"
import { NamedError } from "../util/error"
import type { Tool } from "../tool/tool"
import { SystemPrompt } from "./system"
import { Flag } from "../flag/flag"
import type { ModelsDev } from "../provider/models"
import { Installation } from "../installation"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import { MCP } from "../mcp"
import { Provider } from "../provider/provider"
import { ProviderTransform } from "../provider/transform"
import type { ModelsDev } from "../provider/models"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
import { Storage } from "../storage/storage"
import type { Tool } from "../tool/tool"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
import { SystemPrompt } from "./system"
import { FileTime } from "../file/time"
import { MessageV2 } from "./message-v2"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -53,16 +54,27 @@ export namespace Session {
created: z.number(),
updated: z.number(),
}),
revert: z
.object({
messageID: z.string(),
part: z.number(),
snapshot: z.string().optional(),
})
.optional(),
})
.openapi({
ref: "session.info",
ref: "Session",
})
export type Info = z.output<typeof Info>
export const ShareInfo = z.object({
secret: z.string(),
url: z.string(),
})
export const ShareInfo = z
.object({
secret: z.string(),
url: z.string(),
})
.openapi({
ref: "SessionShare",
})
export type ShareInfo = z.output<typeof ShareInfo>
export const Event = {
@@ -78,10 +90,17 @@ export namespace Session {
info: Info,
}),
),
Idle: Bus.event(
"session.idle",
z.object({
sessionID: z.string(),
}),
),
Error: Bus.event(
"session.error",
z.object({
error: Message.Info.shape.metadata.shape.error,
sessionID: z.string().optional(),
error: MessageV2.Assistant.shape.error,
}),
),
}
@@ -90,7 +109,7 @@ export namespace Session {
"session",
() => {
const sessions = new Map<string, Info>()
const messages = new Map<string, Message.Info[]>()
const messages = new Map<string, MessageV2.Info[]>()
const pending = new Map<string, AbortController>()
return {
@@ -111,9 +130,7 @@ export namespace Session {
id: Identifier.descending("session"),
version: Installation.VERSION,
parentID,
title:
(parentID ? "Child session - " : "New Session - ") +
new Date().toISOString(),
title: (parentID ? "Child session - " : "New Session - ") + new Date().toISOString(),
time: {
created: Date.now(),
updated: Date.now(),
@@ -167,11 +184,13 @@ export namespace Session {
}
export async function unshare(id: string) {
const share = await getShare(id)
if (!share) return
await Storage.remove("session/share/" + id)
await update(id, (draft) => {
draft.share = undefined
})
await Share.remove(id)
await Share.remove(id, share.secret)
}
export async function update(id: string, editor: (session: Info) => void) {
@@ -189,10 +208,10 @@ export namespace Session {
}
export async function messages(sessionID: string) {
const result = [] as Message.Info[]
const result = [] as MessageV2.Info[]
const list = Storage.list("session/message/" + sessionID)
for await (const p of list) {
const read = await Storage.readJSON<Message.Info>(p)
const read = await Storage.readJSON<MessageV2.Info>(p)
result.push(read)
}
result.sort((a, b) => (a.id > b.id ? 1 : -1))
@@ -200,9 +219,7 @@ export namespace Session {
}
export async function getMessage(sessionID: string, messageID: string) {
return Storage.readJSON<Message.Info>(
"session/message/" + sessionID + "/" + messageID,
)
return Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID)
}
export async function* list() {
@@ -253,12 +270,9 @@ export namespace Session {
}
}
async function updateMessage(msg: Message.Info) {
await Storage.writeJSON(
"session/message/" + msg.metadata.sessionID + "/" + msg.id,
msg,
)
Bus.publish(Message.Event.Updated, {
async function updateMessage(msg: MessageV2.Info) {
await Storage.writeJSON("session/message/" + msg.sessionID + "/" + msg.id, msg)
Bus.publish(MessageV2.Event.Updated, {
info: msg,
})
}
@@ -267,27 +281,50 @@ export namespace Session {
sessionID: string
providerID: string
modelID: string
parts: Message.Part[]
parts: MessageV2.UserPart[]
system?: string[]
tools?: Tool.Info[]
}) {
using abort = lock(input.sessionID)
const l = log.clone().tag("session", input.sessionID)
l.info("chatting")
const model = await Provider.getModel(input.providerID, input.modelID)
let msgs = await messages(input.sessionID)
const previous = msgs.at(-1)
const session = await get(input.sessionID)
if (session.revert) {
const trimmed = []
for (const msg of msgs) {
if (msg.id > session.revert.messageID || (msg.id === session.revert.messageID && session.revert.part === 0)) {
await Storage.remove("session/message/" + input.sessionID + "/" + msg.id)
await Bus.publish(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: msg.id,
})
continue
}
if (msg.id === session.revert.messageID) {
if (session.revert.part === 0) break
msg.parts = msg.parts.slice(0, session.revert.part)
}
trimmed.push(msg)
}
msgs = trimmed
await update(input.sessionID, (draft) => {
draft.revert = undefined
})
}
const previous = msgs.at(-1) as MessageV2.Assistant
// auto summarize if too long
if (previous?.metadata.assistant) {
if (previous) {
const tokens =
previous.metadata.assistant.tokens.input +
previous.metadata.assistant.tokens.cache.read +
previous.metadata.assistant.tokens.cache.write +
previous.metadata.assistant.tokens.output
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
if (
model.info.limit.context &&
tokens >
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
tokens > Math.max((model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9, 0)
) {
await summarize({
sessionID: input.sessionID,
@@ -298,31 +335,77 @@ export namespace Session {
}
}
using abort = lock(input.sessionID)
const lastSummary = msgs.findLast(
(msg) => msg.metadata.assistant?.summary === true,
)
const lastSummary = msgs.findLast((msg) => msg.role === "assistant" && msg.summary === true)
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
const app = App.info()
const session = await get(input.sessionID)
input.parts = await Promise.all(
input.parts.map(async (part): Promise<MessageV2.UserPart[]> => {
if (part.type === "file") {
const url = new URL(part.url)
switch (url.protocol) {
case "file:":
const filepath = path.join(app.path.cwd, url.pathname)
let file = Bun.file(filepath)
if (part.mime === "text/plain") {
let text = await file.text()
const range = {
start: url.searchParams.get("start"),
end: url.searchParams.get("end"),
}
if (range.start != null && part.mime === "text/plain") {
const lines = text.split("\n")
const start = parseInt(range.start)
const end = range.end ? parseInt(range.end) : lines.length
text = lines.slice(start, end).join("\n")
}
FileTime.read(input.sessionID, filepath)
return [
{
type: "text",
text: ["Called the Read tool on " + url.pathname, "<results>", text, "</results>"].join("\n"),
},
]
}
return [
{
type: "text",
text: `Called the Read tool with the following input: {\"filePath\":\"${url.pathname}\"}`,
},
{
type: "file",
url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
mime: part.mime,
filename: part.filename!,
},
]
}
}
return [part]
}),
).then((x) => x.flat())
if (msgs.length === 0 && !session.parentID) {
generateText({
maxTokens: input.providerID === "google" ? 1024 : 20,
maxOutputTokens: input.providerID === "google" ? 1024 : 20,
providerOptions: model.info.options,
messages: [
...SystemPrompt.title(input.providerID).map(
(x): CoreMessage => ({
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...convertToCoreMessages([
...MessageV2.toModelMessage([
{
id: Identifier.ascending("message"),
role: "user",
content: "",
parts: toParts(input.parts),
sessionID: input.sessionID,
parts: input.parts,
time: {
created: Date.now(),
},
},
]),
],
@@ -336,16 +419,13 @@ export namespace Session {
})
.catch(() => {})
}
const msg: Message.Info = {
role: "user",
const msg: MessageV2.Info = {
id: Identifier.ascending("message"),
role: "user",
sessionID: input.sessionID,
parts: input.parts,
metadata: {
time: {
created: Date.now(),
},
sessionID: input.sessionID,
tool: {},
time: {
created: Date.now(),
},
}
await updateMessage(msg)
@@ -355,33 +435,28 @@ export namespace Session {
system.push(...(await SystemPrompt.environment()))
system.push(...(await SystemPrompt.custom()))
const next: Message.Info = {
const next: MessageV2.Info = {
id: Identifier.ascending("message"),
role: "assistant",
parts: [],
metadata: {
assistant: {
system,
path: {
cwd: app.path.cwd,
root: app.path.root,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: input.modelID,
providerID: input.providerID,
},
time: {
created: Date.now(),
},
sessionID: input.sessionID,
tool: {},
system,
path: {
cwd: app.path.cwd,
root: app.path.root,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: input.modelID,
providerID: input.providerID,
time: {
created: Date.now(),
},
sessionID: input.sessionID,
}
await updateMessage(next)
const tools: Record<string, AITool> = {}
@@ -390,46 +465,29 @@ export namespace Session {
tools[item.id.replaceAll(".", "_")] = tool({
id: item.id as any,
description: item.description,
parameters: item.parameters as ZodSchema,
inputSchema: item.parameters as ZodSchema,
async execute(args, opts) {
const start = Date.now()
try {
const result = await item.execute(args, {
sessionID: input.sessionID,
abort: abort.signal,
messageID: next.id,
metadata: async (val) => {
next.metadata.tool[opts.toolCallId] = {
...val,
time: {
start: 0,
end: 0,
},
}
await updateMessage(next)
},
})
next.metadata!.tool![opts.toolCallId] = {
...result.metadata,
time: {
start,
end: Date.now(),
},
}
await updateMessage(next)
return result.output
} catch (e: any) {
next.metadata!.tool![opts.toolCallId] = {
error: true,
message: e.toString(),
title: e.toString(),
time: {
start,
end: Date.now(),
},
}
await updateMessage(next)
return e.toString()
const result = await item.execute(args, {
sessionID: input.sessionID,
abort: abort.signal,
messageID: next.id,
metadata: async (val) => {
const match = next.parts.find(
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === opts.toolCallId,
)
if (match && match.state.status === "running") {
match.state.title = val.title
match.state.metadata = val.metadata
}
await updateMessage(next)
},
})
return result
},
toModelOutput(result) {
return {
type: "text",
value: result.output,
}
},
})
@@ -439,114 +497,44 @@ export namespace Session {
const execute = item.execute
if (!execute) continue
item.execute = async (args, opts) => {
const start = Date.now()
try {
const result = await execute(args, opts)
next.metadata!.tool![opts.toolCallId] = {
...result.metadata,
time: {
start,
end: Date.now(),
},
}
await updateMessage(next)
return result.content
.filter((x: any) => x.type === "text")
.map((x: any) => x.text)
.join("\n\n")
} catch (e: any) {
next.metadata!.tool![opts.toolCallId] = {
error: true,
message: e.toString(),
title: "mcp",
time: {
start,
end: Date.now(),
},
}
await updateMessage(next)
return e.toString()
const result = await execute(args, opts)
const output = result.content
.filter((x: any) => x.type === "text")
.map((x: any) => x.text)
.join("\n\n")
return {
output,
}
}
item.toModelOutput = (result) => {
return {
type: "text",
value: result.output,
}
}
tools[key] = item
}
let text: Message.TextPart | undefined
let text: MessageV2.TextPart = {
type: "text",
text: "",
}
const result = streamText({
onStepFinish: async (step) => {
log.info("step finish", { finishReason: step.finishReason })
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, step.usage, step.providerMetadata)
assistant.cost += usage.cost
assistant.tokens = usage.tokens
await updateMessage(next)
if (text) {
Bus.publish(Message.Event.PartUpdated, {
part: text,
messageID: next.id,
sessionID: next.metadata.sessionID,
})
}
text = undefined
},
async onFinish(input) {
log.info("message finish", {
reason: input.finishReason,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, input.usage, input.providerMetadata)
assistant.cost = usage.cost
await updateMessage(next)
},
onError(err) {
log.error("callback error", err)
switch (true) {
case LoadAPIKeyError.isInstance(err.error):
next.metadata.error = new Provider.AuthError(
{
providerID: input.providerID,
message: err.error.message,
},
{ cause: err.error },
).toObject()
break
case err.error instanceof Error:
next.metadata.error = new NamedError.Unknown(
{ message: err.error.toString() },
{ cause: err.error },
).toObject()
break
default:
next.metadata.error = new NamedError.Unknown(
{ message: JSON.stringify(err.error) },
{ cause: err.error },
)
}
Bus.publish(Event.Error, {
error: next.metadata.error,
})
},
// async prepareStep(step) {
// next.parts.push({
// type: "step-start",
// })
// await updateMessage(next)
// return step
// },
toolCallStreaming: true,
onError() {},
maxRetries: 10,
maxOutputTokens: Math.max(0, model.info.limit.output) || undefined,
abortSignal: abort.signal,
maxSteps: 1000,
stopWhen: stepCountIs(1000),
providerOptions: model.info.options,
messages: [
...system.map(
(x): CoreMessage => ({
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
),
...MessageV2.toModelMessage(msgs),
],
temperature: model.info.temperature ? 0 : undefined,
tools: model.info.tool_call === false ? undefined : tools,
@@ -556,11 +544,8 @@ export namespace Session {
{
async transformParams(args) {
if (args.type === "stream") {
args.params.prompt = ProviderTransform.message(
args.params.prompt,
input.providerID,
input.modelID,
)
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.providerID, input.modelID)
}
return args.params
},
@@ -574,104 +559,161 @@ export namespace Session {
type: value.type,
})
switch (value.type) {
case "step-start":
case "start":
break
case "tool-input-start":
next.parts.push({
type: "tool",
tool: value.toolName,
id: value.id,
state: {
status: "pending",
},
})
Bus.publish(MessageV2.Event.PartUpdated, {
part: next.parts[next.parts.length - 1],
sessionID: next.sessionID,
messageID: next.id,
})
break
case "tool-input-delta":
break
case "tool-call": {
const match = next.parts.find(
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId,
)
if (match) {
match.state = {
status: "running",
input: value.input,
time: {
start: Date.now(),
},
}
Bus.publish(MessageV2.Event.PartUpdated, {
part: match,
sessionID: next.sessionID,
messageID: next.id,
})
}
break
}
case "tool-result": {
const match = next.parts.find(
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId,
)
if (match && match.state.status === "running") {
match.state = {
status: "completed",
input: value.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
time: {
start: match.state.time.start,
end: Date.now(),
},
}
Bus.publish(MessageV2.Event.PartUpdated, {
part: match,
sessionID: next.sessionID,
messageID: next.id,
})
}
break
}
case "tool-error": {
const match = next.parts.find(
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId,
)
if (match && match.state.status === "running") {
match.state = {
status: "error",
input: value.input,
error: (value.error as any).toString(),
time: {
start: match.state.time.start,
end: Date.now(),
},
}
Bus.publish(MessageV2.Event.PartUpdated, {
part: match,
sessionID: next.sessionID,
messageID: next.id,
})
}
break
}
case "error":
throw value.error
case "start-step":
next.parts.push({
type: "step-start",
})
break
case "text-delta":
if (!text) {
text = {
type: "text",
text: value.textDelta,
}
next.parts.push(text)
break
} else text.text += value.textDelta
case "finish-step":
const usage = getUsage(model.info, value.usage, value.providerMetadata)
next.cost += usage.cost
next.tokens = usage.tokens
break
case "tool-call": {
const [match] = next.parts.flatMap((p) =>
p.type === "tool-invocation" &&
p.toolInvocation.toolCallId === value.toolCallId
? [p]
: [],
)
if (!match) break
match.toolInvocation.args = value.args
match.toolInvocation.state = "call"
Bus.publish(Message.Event.PartUpdated, {
part: match,
messageID: next.id,
sessionID: next.metadata.sessionID,
})
break
}
case "tool-call-streaming-start":
next.parts.push({
type: "tool-invocation",
toolInvocation: {
state: "partial-call",
toolName: value.toolName,
toolCallId: value.toolCallId,
args: {},
},
})
Bus.publish(Message.Event.PartUpdated, {
part: next.parts[next.parts.length - 1],
messageID: next.id,
sessionID: next.metadata.sessionID,
})
break
case "tool-call-delta":
continue
// for some reason ai sdk claims to not send this part but it does
// @ts-expect-error
case "tool-result":
const match = next.parts.find(
(p) =>
p.type === "tool-invocation" &&
// @ts-expect-error
p.toolInvocation.toolCallId === value.toolCallId,
)
if (match && match.type === "tool-invocation") {
match.toolInvocation = {
// @ts-expect-error
args: value.args,
// @ts-expect-error
toolCallId: value.toolCallId,
// @ts-expect-error
toolName: value.toolName,
state: "result",
// @ts-expect-error
result: value.result as string,
}
Bus.publish(Message.Event.PartUpdated, {
part: match,
messageID: next.id,
sessionID: next.metadata.sessionID,
})
case "text-start":
text = {
type: "text",
text: "",
}
break
case "text":
if (text.text === "") next.parts.push(text)
text.text += value.text
break
case "text-end":
Bus.publish(MessageV2.Event.PartUpdated, {
part: text,
sessionID: next.sessionID,
messageID: next.id,
})
break
case "finish":
next.time.completed = Date.now()
break
default:
l.info("unhandled", {
type: value.type,
...value,
})
continue
}
await updateMessage(next)
}
} catch (e: any) {
log.error("stream error", {
} catch (e) {
log.error("", {
error: e,
})
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
next.error = new MessageV2.AbortedError(
{ message: e.message },
{
cause: e,
},
).toObject()
break
case MessageV2.OutputLengthError.isInstance(e):
next.error = e
break
case LoadAPIKeyError.isInstance(e):
next.metadata.error = new Provider.AuthError(
next.error = new Provider.AuthError(
{
providerID: input.providerID,
message: e.message,
@@ -680,96 +722,124 @@ export namespace Session {
).toObject()
break
case e instanceof Error:
next.metadata.error = new NamedError.Unknown(
{ message: e.toString() },
{ cause: e },
).toObject()
next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
break
default:
next.metadata.error = new NamedError.Unknown(
{ message: JSON.stringify(e) },
{ cause: e },
)
next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e })
}
Bus.publish(Event.Error, {
error: next.metadata.error,
sessionID: next.sessionID,
error: next.error,
})
}
next.metadata!.time.completed = Date.now()
for (const part of next.parts) {
if (
part.type === "tool-invocation" &&
part.toolInvocation.state !== "result"
) {
part.toolInvocation = {
...part.toolInvocation,
state: "result",
result: "request was aborted",
if (part.type === "tool" && part.state.status !== "completed") {
part.state = {
status: "error",
error: "Tool execution aborted",
time: {
start: Date.now(),
end: Date.now(),
},
input: {},
}
}
}
next.time.completed = Date.now()
await updateMessage(next)
return next
}
export async function summarize(input: {
sessionID: string
providerID: string
modelID: string
}) {
export async function revert(_input: { sessionID: string; messageID: string; part: number }) {
// TODO
/*
const message = await getMessage(input.sessionID, input.messageID)
if (!message) return
const part = message.parts[input.part]
if (!part) return
const session = await get(input.sessionID)
const snapshot =
session.revert?.snapshot ?? (await Snapshot.create(input.sessionID))
const old = (() => {
if (message.role === "assistant") {
const lastTool = message.parts.findLast(
(part, index) =>
part.type === "tool-invocation" && index < input.part,
)
if (lastTool && lastTool.type === "tool-invocation")
return message.metadata.tool[lastTool.toolInvocation.toolCallId]
.snapshot
}
return message.metadata.snapshot
})()
if (old) await Snapshot.restore(input.sessionID, old)
await update(input.sessionID, (draft) => {
draft.revert = {
messageID: input.messageID,
part: input.part,
snapshot,
}
})
*/
}
export async function unrevert(sessionID: string) {
const session = await get(sessionID)
if (!session) return
if (!session.revert) return
if (session.revert.snapshot) await Snapshot.restore(sessionID, session.revert.snapshot)
update(sessionID, (draft) => {
draft.revert = undefined
})
}
export async function summarize(input: { sessionID: string; providerID: string; modelID: string }) {
using abort = lock(input.sessionID)
const msgs = await messages(input.sessionID)
const lastSummary = msgs.findLast(
(msg) => msg.metadata.assistant?.summary === true,
)?.id
const lastSummary = msgs.findLast((msg) => msg.role === "assistant" && msg.summary === true)?.id
const filtered = msgs.filter((msg) => !lastSummary || msg.id >= lastSummary)
const model = await Provider.getModel(input.providerID, input.modelID)
const app = App.info()
const system = SystemPrompt.summarize(input.providerID)
const next: Message.Info = {
const next: MessageV2.Info = {
id: Identifier.ascending("message"),
role: "assistant",
parts: [],
metadata: {
tool: {},
sessionID: input.sessionID,
assistant: {
system,
path: {
cwd: app.path.cwd,
root: app.path.root,
},
summary: true,
cost: 0,
modelID: input.modelID,
providerID: input.providerID,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
time: {
created: Date.now(),
},
sessionID: input.sessionID,
system,
path: {
cwd: app.path.cwd,
root: app.path.root,
},
summary: true,
cost: 0,
modelID: input.modelID,
providerID: input.providerID,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
time: {
created: Date.now(),
},
}
await updateMessage(next)
let text: Message.TextPart | undefined
let text: MessageV2.TextPart | undefined
const result = streamText({
abortSignal: abort.signal,
model: model.language,
messages: [
...system.map(
(x): CoreMessage => ({
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...convertToCoreMessages(filtered.map(toUIMessage)),
...MessageV2.toModelMessage(filtered),
{
role: "user",
content: [
@@ -781,45 +851,80 @@ export namespace Session {
},
],
onStepFinish: async (step) => {
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, step.usage, step.providerMetadata)
assistant.cost += usage.cost
assistant.tokens = usage.tokens
next.cost += usage.cost
next.tokens = usage.tokens
await updateMessage(next)
if (text) {
Bus.publish(Message.Event.PartUpdated, {
Bus.publish(MessageV2.Event.PartUpdated, {
part: text,
messageID: next.id,
sessionID: next.metadata.sessionID,
sessionID: next.sessionID,
})
}
text = undefined
},
async onFinish(input) {
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, input.usage, input.providerMetadata)
assistant.cost = usage.cost
assistant.tokens = usage.tokens
next.metadata!.time.completed = Date.now()
next.cost += usage.cost
next.tokens = usage.tokens
next.time.completed = Date.now()
await updateMessage(next)
},
})
for await (const value of result.fullStream) {
switch (value.type) {
case "text-delta":
if (!text) {
text = {
type: "text",
text: value.textDelta,
}
next.parts.push(text)
} else text.text += value.textDelta
await updateMessage(next)
break
try {
for await (const value of result.fullStream) {
switch (value.type) {
case "text":
if (!text) {
text = {
type: "text",
text: value.text,
}
next.parts.push(text)
} else text.text += value.text
await updateMessage(next)
break
}
}
} catch (e: any) {
log.error("summarize stream error", {
error: e,
})
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
next.error = new MessageV2.AbortedError(
{ message: e.message },
{
cause: e,
},
).toObject()
break
case MessageV2.OutputLengthError.isInstance(e):
next.error = e
break
case LoadAPIKeyError.isInstance(e):
next.error = new Provider.AuthError(
{
providerID: input.providerID,
message: e.message,
},
{ cause: e },
).toObject()
break
case e instanceof Error:
next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
break
default:
next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject()
}
Bus.publish(Event.Error, {
error: next.error,
})
}
next.time.completed = Date.now()
await updateMessage(next)
}
function lock(sessionID: string) {
@@ -832,40 +937,32 @@ export namespace Session {
[Symbol.dispose]() {
log.info("unlocking", { sessionID })
state().pending.delete(sessionID)
Bus.publish(Event.Idle, {
sessionID,
})
},
}
}
function getUsage(
model: ModelsDev.Model,
usage: LanguageModelUsage,
metadata?: ProviderMetadata,
) {
function getUsage(model: ModelsDev.Model, usage: LanguageModelUsage, metadata?: ProviderMetadata) {
const tokens = {
input: usage.promptTokens ?? 0,
output: usage.completionTokens ?? 0,
input: usage.inputTokens ?? 0,
output: usage.outputTokens ?? 0,
reasoning: 0,
cache: {
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
0) as number,
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
0) as number,
read: usage.cachedInputTokens ?? 0,
},
}
return {
cost: new Decimal(0)
.add(new Decimal(tokens.input).mul(model.cost.input).div(1_000_000))
.add(new Decimal(tokens.output).mul(model.cost.output).div(1_000_000))
.add(
new Decimal(tokens.cache.read)
.mul(model.cost.cache_read ?? 0)
.div(1_000_000),
)
.add(
new Decimal(tokens.cache.write)
.mul(model.cost.cache_write ?? 0)
.div(1_000_000),
)
.add(new Decimal(tokens.cache.read).mul(model.cost.cache_read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(model.cost.cache_write ?? 0).div(1_000_000))
.toNumber(),
tokens,
}
@@ -877,11 +974,7 @@ export namespace Session {
}
}
export async function initialize(input: {
sessionID: string
modelID: string
providerID: string
}) {
export async function initialize(input: { sessionID: string; modelID: string; providerID: string }) {
const app = App.info()
await Session.chat({
sessionID: input.sessionID,
@@ -897,57 +990,3 @@ export namespace Session {
await App.initialize()
}
}
function toUIMessage(msg: Message.Info): UIMessage {
if (msg.role === "assistant") {
return {
id: msg.id,
role: "assistant",
content: "",
parts: toParts(msg.parts),
}
}
if (msg.role === "user") {
return {
id: msg.id,
role: "user",
content: "",
parts: toParts(msg.parts),
}
}
throw new Error("not implemented")
}
function toParts(parts: Message.Part[]): UIMessage["parts"] {
const result: UIMessage["parts"] = []
for (const part of parts) {
switch (part.type) {
case "text":
result.push({ type: "text", text: part.text })
break
case "file":
result.push({
type: "file",
data: part.url,
mimeType: part.mediaType,
})
break
case "tool-invocation":
result.push({
type: "tool-invocation",
toolInvocation: part.toolInvocation,
})
break
case "step-start":
result.push({
type: "step-start",
})
break
default:
break
}
}
return result
}
+404
View File
@@ -0,0 +1,404 @@
import z from "zod"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
import { Message } from "./message"
import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AbortedError = NamedError.create("MessageAbortedError", z.object({}))
export const ToolStatePending = z
.object({
status: z.literal("pending"),
})
.openapi({
ref: "ToolStatePending",
})
export type ToolStatePending = z.infer<typeof ToolStatePending>
export const ToolStateRunning = z
.object({
status: z.literal("running"),
input: z.any(),
title: z.string().optional(),
metadata: z.record(z.any()).optional(),
time: z.object({
start: z.number(),
}),
})
.openapi({
ref: "ToolStateRunning",
})
export type ToolStateRunning = z.infer<typeof ToolStateRunning>
export const ToolStateCompleted = z
.object({
status: z.literal("completed"),
input: z.record(z.any()),
output: z.string(),
title: z.string(),
metadata: z.record(z.any()),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.openapi({
ref: "ToolStateCompleted",
})
export type ToolStateCompleted = z.infer<typeof ToolStateCompleted>
export const ToolStateError = z
.object({
status: z.literal("error"),
input: z.record(z.any()),
error: z.string(),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.openapi({
ref: "ToolStateError",
})
export type ToolStateError = z.infer<typeof ToolStateError>
export const ToolState = z
.discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
.openapi({
ref: "ToolState",
})
export const TextPart = z
.object({
type: z.literal("text"),
text: z.string(),
})
.openapi({
ref: "TextPart",
})
export type TextPart = z.infer<typeof TextPart>
export const ToolPart = z
.object({
type: z.literal("tool"),
id: z.string(),
tool: z.string(),
state: ToolState,
})
.openapi({
ref: "ToolPart",
})
export type ToolPart = z.infer<typeof ToolPart>
export const FilePart = z
.object({
type: z.literal("file"),
mime: z.string(),
filename: z.string().optional(),
url: z.string(),
})
.openapi({
ref: "FilePart",
})
export type FilePart = z.infer<typeof FilePart>
export const StepStartPart = z
.object({
type: z.literal("step-start"),
})
.openapi({
ref: "StepStartPart",
})
export type StepStartPart = z.infer<typeof StepStartPart>
const Base = z.object({
id: z.string(),
sessionID: z.string(),
})
export const UserPart = z.discriminatedUnion("type", [TextPart, FilePart]).openapi({
ref: "UserMessagePart",
})
export type UserPart = z.infer<typeof UserPart>
export const User = Base.extend({
role: z.literal("user"),
parts: z.array(UserPart),
time: z.object({
created: z.number(),
}),
}).openapi({
ref: "UserMessage",
})
export type User = z.infer<typeof User>
export const AssistantPart = z.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart]).openapi({
ref: "AssistantMessagePart",
})
export type AssistantPart = z.infer<typeof AssistantPart>
export const Assistant = Base.extend({
role: z.literal("assistant"),
parts: z.array(AssistantPart),
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [
Provider.AuthError.Schema,
NamedError.Unknown.Schema,
OutputLengthError.Schema,
AbortedError.Schema,
])
.optional(),
system: z.string().array(),
modelID: z.string(),
providerID: z.string(),
path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
}).openapi({
ref: "AssistantMessage",
})
export type Assistant = z.infer<typeof Assistant>
export const Info = z.discriminatedUnion("role", [User, Assistant]).openapi({
ref: "Message",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
"message.updated",
z.object({
info: Info,
}),
),
Removed: Bus.event(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
}),
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({
part: AssistantPart,
sessionID: z.string(),
messageID: z.string(),
}),
),
}
export function fromV1(v1: Message.Info) {
if (v1.role === "assistant") {
const result: Assistant = {
id: v1.id,
sessionID: v1.metadata.sessionID,
role: "assistant",
time: {
created: v1.metadata.time.created,
completed: v1.metadata.time.completed,
},
cost: v1.metadata.assistant!.cost,
path: v1.metadata.assistant!.path,
summary: v1.metadata.assistant!.summary,
tokens: v1.metadata.assistant!.tokens,
modelID: v1.metadata.assistant!.modelID,
providerID: v1.metadata.assistant!.providerID,
system: v1.metadata.assistant!.system,
error: v1.metadata.error,
parts: v1.parts.flatMap((part): AssistantPart[] => {
if (part.type === "text") {
return [
{
type: "text",
text: part.text,
},
]
}
if (part.type === "step-start") {
return [
{
type: "step-start",
},
]
}
if (part.type === "tool-invocation") {
return [
{
type: "tool",
id: part.toolInvocation.toolCallId,
tool: part.toolInvocation.toolName,
state: (() => {
if (part.toolInvocation.state === "partial-call") {
return {
status: "pending",
}
}
const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] ?? {}
if (part.toolInvocation.state === "call") {
return {
status: "running",
input: part.toolInvocation.args,
time: {
start: time?.start,
},
}
}
if (part.toolInvocation.state === "result") {
return {
status: "completed",
input: part.toolInvocation.args,
output: part.toolInvocation.result,
title,
time,
metadata,
}
}
throw new Error("unknown tool invocation state")
})(),
},
]
}
return []
}),
}
return result
}
if (v1.role === "user") {
const result: User = {
id: v1.id,
sessionID: v1.metadata.sessionID,
role: "user",
time: {
created: v1.metadata.time.created,
},
parts: v1.parts.flatMap((part): UserPart[] => {
if (part.type === "text") {
return [
{
type: "text",
text: part.text,
},
]
}
if (part.type === "file") {
return [
{
type: "file",
mime: part.mediaType,
filename: part.filename,
url: part.url,
},
]
}
return []
}),
}
return result
}
}
export function toModelMessage(input: Info[]): ModelMessage[] {
const result: UIMessage[] = []
for (const msg of input) {
if (msg.parts.length === 0) continue
if (msg.role === "user") {
result.push({
id: msg.id,
role: "user",
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
if (part.type === "text")
return [
{
type: "text",
text: part.text,
},
]
if (part.type === "file")
return [
{
type: "file",
url: part.url,
mediaType: part.mime,
filename: part.filename,
},
]
return []
}),
})
}
if (msg.role === "assistant") {
result.push({
id: msg.id,
role: "assistant",
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
if (part.type === "text")
return [
{
type: "text",
text: part.text,
},
]
if (part.type === "step-start")
return [
{
type: "step-start",
},
]
if (part.type === "tool") {
if (part.state.status === "completed")
return [
{
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.id,
input: part.state.input,
output: part.state.output,
},
]
if (part.state.status === "error")
return [
{
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.id,
input: part.state.input,
errorText: part.state.error,
},
]
}
return []
}),
})
}
}
return convertToModelMessages(result)
}
}
+24 -42
View File
@@ -1,9 +1,10 @@
import z from "zod"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
export namespace Message {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const ToolCall = z
.object({
state: z.literal("call"),
@@ -13,7 +14,7 @@ export namespace Message {
args: z.custom<Required<unknown>>(),
})
.openapi({
ref: "Message.ToolInvocation.ToolCall",
ref: "ToolCall",
})
export type ToolCall = z.infer<typeof ToolCall>
@@ -26,7 +27,7 @@ export namespace Message {
args: z.custom<Required<unknown>>(),
})
.openapi({
ref: "Message.ToolInvocation.ToolPartialCall",
ref: "ToolPartialCall",
})
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
@@ -40,15 +41,13 @@ export namespace Message {
result: z.string(),
})
.openapi({
ref: "Message.ToolInvocation.ToolResult",
ref: "ToolResult",
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocation = z
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
.openapi({
ref: "Message.ToolInvocation",
})
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).openapi({
ref: "ToolInvocation",
})
export type ToolInvocation = z.infer<typeof ToolInvocation>
export const TextPart = z
@@ -57,7 +56,7 @@ export namespace Message {
text: z.string(),
})
.openapi({
ref: "Message.Part.Text",
ref: "TextPart",
})
export type TextPart = z.infer<typeof TextPart>
@@ -68,7 +67,7 @@ export namespace Message {
providerMetadata: z.record(z.any()).optional(),
})
.openapi({
ref: "Message.Part.Reasoning",
ref: "ReasoningPart",
})
export type ReasoningPart = z.infer<typeof ReasoningPart>
@@ -78,7 +77,7 @@ export namespace Message {
toolInvocation: ToolInvocation,
})
.openapi({
ref: "Message.Part.ToolInvocation",
ref: "ToolInvocationPart",
})
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
@@ -91,7 +90,7 @@ export namespace Message {
providerMetadata: z.record(z.any()).optional(),
})
.openapi({
ref: "Message.Part.SourceUrl",
ref: "SourceUrlPart",
})
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
@@ -103,7 +102,7 @@ export namespace Message {
url: z.string(),
})
.openapi({
ref: "Message.Part.File",
ref: "FilePart",
})
export type FilePart = z.infer<typeof FilePart>
@@ -112,29 +111,22 @@ export namespace Message {
type: z.literal("step-start"),
})
.openapi({
ref: "Message.Part.StepStart",
ref: "StepStartPart",
})
export type StepStartPart = z.infer<typeof StepStartPart>
export const Part = z
.discriminatedUnion("type", [
TextPart,
ReasoningPart,
ToolInvocationPart,
SourceUrlPart,
FilePart,
StepStartPart,
])
export const MessagePart = z
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
.openapi({
ref: "Message.Part",
ref: "MessagePart",
})
export type Part = z.infer<typeof Part>
export type MessagePart = z.infer<typeof MessagePart>
export const Info = z
.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(Part),
parts: z.array(MessagePart),
metadata: z
.object({
time: z.object({
@@ -145,6 +137,7 @@ export namespace Message {
.discriminatedUnion("name", [
Provider.AuthError.Schema,
NamedError.Unknown.Schema,
OutputLengthError.Schema,
])
.optional(),
sessionID: z.string(),
@@ -153,6 +146,7 @@ export namespace Message {
z
.object({
title: z.string(),
snapshot: z.string().optional(),
time: z.object({
start: z.number(),
end: z.number(),
@@ -182,24 +176,12 @@ export namespace Message {
}),
})
.optional(),
snapshot: z.string().optional(),
})
.openapi({ ref: "Message.Metadata" }),
.openapi({ ref: "MessageMetadata" }),
})
.openapi({
ref: "Message.Info",
ref: "Message",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
"message.updated",
z.object({
info: Info,
}),
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({ part: Part, sessionID: z.string(), messageID: z.string() }),
),
}
}
@@ -134,7 +134,7 @@ The user will primarily request you perform software engineering tasks. This inc
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
+26 -53
View File
@@ -1,7 +1,8 @@
import { App } from "../app/app"
import { Ripgrep } from "../external/ripgrep"
import { Ripgrep } from "../file/ripgrep"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import path from "path"
import os from "os"
@@ -27,55 +28,6 @@ export namespace SystemPrompt {
export async function environment() {
const app = App.info()
;async () => {
const files = await Ripgrep.files({
cwd: app.path.cwd,
})
type Node = {
children: Record<string, Node>
}
const root: Node = {
children: {},
}
for (const file of files) {
const parts = file.split("/")
let node = root
for (const part of parts) {
const existing = node.children[part]
if (existing) {
node = existing
continue
}
node.children[part] = {
children: {},
}
node = node.children[part]
}
}
function render(path: string[], node: Node): string {
// if (path.length === 3) return "\t".repeat(path.length) + "..."
const lines: string[] = []
const entries = Object.entries(node.children).sort(([a], [b]) =>
a.localeCompare(b),
)
for (const [name, child] of entries) {
const currentPath = [...path, name]
const indent = "\t".repeat(path.length)
const hasChildren = Object.keys(child.children).length > 0
lines.push(`${indent}${name}` + (hasChildren ? "/" : ""))
if (hasChildren) lines.push(render(currentPath, child))
}
return lines.join("\n")
}
const result = render([], root)
return result
}
return [
[
`Here is some useful information about the environment you are running in:`,
@@ -85,9 +37,16 @@ export namespace SystemPrompt {
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
// `<project>`,
// ` ${app.git ? await tree() : ""}`,
// `</project>`,
`<project>`,
` ${
app.git
? await Ripgrep.tree({
cwd: app.path.cwd,
limit: 200,
})
: ""
}`,
`</project>`,
].join("\n"),
]
}
@@ -97,8 +56,10 @@ export namespace SystemPrompt {
"CLAUDE.md",
"CONTEXT.md", // deprecated
]
export async function custom() {
const { cwd, root } = App.info().path
const config = await Config.get()
const found = []
for (const item of CUSTOM_FILES) {
const matches = await Filesystem.findUp(item, cwd, root)
@@ -114,6 +75,18 @@ export namespace SystemPrompt {
.text()
.catch(() => ""),
)
if (config.instructions) {
for (const instruction of config.instructions) {
try {
const matches = await Filesystem.globUp(instruction, cwd, root)
found.push(...matches.map((x) => Bun.file(x).text()))
} catch {
continue // Skip invalid glob patterns
}
}
}
return Promise.all(found).then((result) => result.filter(Boolean))
}
+7 -14
View File
@@ -1,4 +1,3 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { Installation } from "../installation"
import { Session } from "../session"
@@ -11,12 +10,6 @@ export namespace Share {
let queue: Promise<void> = Promise.resolve()
const pending = new Map<string, any>()
const state = App.state("share", async () => {
Bus.subscribe(Storage.Event.Write, async (payload) => {
await sync(payload.properties.key, payload.properties.content)
})
})
export async function sync(key: string, content: any) {
const [root, ...splits] = key.split("/")
if (root !== "session") return
@@ -52,15 +45,15 @@ export namespace Share {
})
}
export async function init() {
await state()
export function init() {
Bus.subscribe(Storage.Event.Write, async (payload) => {
await sync(payload.properties.key, payload.properties.content)
})
}
export const URL =
process.env["OPENCODE_API"] ??
(Installation.isSnapshot() || Installation.isDev()
? "https://api.dev.opencode.ai"
: "https://api.opencode.ai")
(Installation.isSnapshot() || Installation.isDev() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
export async function create(sessionID: string) {
return fetch(`${URL}/share_create`, {
@@ -71,10 +64,10 @@ export namespace Share {
.then((x) => x as { url: string; secret: string })
}
export async function remove(id: string) {
export async function remove(sessionID: string, secret: string) {
return fetch(`${URL}/share_delete`, {
method: "POST",
body: JSON.stringify({ id }),
body: JSON.stringify({ sessionID, secret }),
}).then((x) => x.json())
}
}
+65
View File
@@ -0,0 +1,65 @@
import { App } from "../app/app"
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
return
log.info("creating snapshot")
const app = App.info()
const git = gitdir(sessionID)
// not a git repo, check if too big to snapshot
if (!app.git) {
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: 1000,
})
log.info("found files", { count: files.length })
if (files.length > 1000) return
}
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: app.path.root,
})
.quiet()
.nothrow()
log.info("initialized")
}
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
log.info("added files")
const result =
await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode <mail@opencode.ai>"`
.quiet()
.cwd(app.path.cwd)
.nothrow()
log.info("commit")
const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/)
if (!match) return
return match![1]
}
export async function restore(sessionID: string, commit: string) {
log.info("restore", { commit })
const app = App.info()
const git = gitdir(sessionID)
await $`git --git-dir=${git} checkout ${commit} --force`.quiet().cwd(app.path.root)
}
function gitdir(sessionID: string) {
const app = App.info()
return path.join(app.path.data, "snapshot", sessionID)
}
}
+50 -13
View File
@@ -4,44 +4,80 @@ import { Bus } from "../bus"
import path from "path"
import z from "zod"
import fs from "fs/promises"
import { MessageV2 } from "../session/message-v2"
export namespace Storage {
const log = Log.create({ service: "storage" })
export const Event = {
Write: Bus.event(
"storage.write",
z.object({ key: z.string(), content: z.any() }),
),
Write: Bus.event("storage.write", z.object({ key: z.string(), content: z.any() })),
}
const state = App.state("storage", () => {
type Migration = (dir: string) => Promise<void>
const MIGRATIONS: Migration[] = [
async (dir: string) => {
try {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
const content = await Bun.file(file).json()
if (!content.metadata) continue
log.info("migrating to v2 message", { file })
try {
const result = MessageV2.fromV1(content)
await Bun.write(file, JSON.stringify(result, null, 2))
} catch (e) {
await fs.rename(file, file.replace("storage", "broken"))
}
}
} catch {}
},
]
const state = App.state("storage", async () => {
const app = App.info()
const dir = path.join(app.path.data, "storage")
log.info("init", { path: dir })
const dir = path.normalize(path.join(app.path.data, "storage"))
await fs.mkdir(dir, { recursive: true })
const migration = await Bun.file(path.join(dir, "migration"))
.json()
.then((x) => parseInt(x))
.catch(() => 0)
for (let index = migration; index < MIGRATIONS.length; index++) {
log.info("running migration", { index })
const migration = MIGRATIONS[index]
await migration(dir)
await Bun.write(path.join(dir, "migration"), (index + 1).toString())
}
return {
dir,
}
})
export async function remove(key: string) {
const target = path.join(state().dir, key + ".json")
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key + ".json")
await fs.unlink(target).catch(() => {})
}
export async function removeDir(key: string) {
const target = path.join(state().dir, key)
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key)
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
}
export async function readJSON<T>(key: string) {
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
const dir = await state().then((x) => x.dir)
return Bun.file(path.join(dir, key + ".json")).json() as Promise<T>
}
export async function writeJSON<T>(key: string, content: T) {
const target = path.join(state().dir, key + ".json")
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key + ".json")
const tmp = target + Date.now() + ".tmp"
await Bun.write(tmp, JSON.stringify(content))
await Bun.write(tmp, JSON.stringify(content, null, 2))
await fs.rename(tmp, target).catch(() => {})
await fs.unlink(tmp).catch(() => {})
Bus.publish(Event.Write, { key, content })
@@ -49,9 +85,10 @@ export namespace Storage {
const glob = new Bun.Glob("**/*")
export async function* list(prefix: string) {
const dir = await state().then((x) => x.dir)
try {
for await (const item of glob.scan({
cwd: path.join(state().dir, prefix),
cwd: path.join(dir, prefix),
onlyFiles: true,
})) {
const result = path.join(prefix, item.slice(0, -5))
+5 -36
View File
@@ -1,27 +1,9 @@
import { z } from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./bash.txt"
import { App } from "../app/app"
const MAX_OUTPUT_LENGTH = 30000
const BANNED_COMMANDS = [
"alias",
"curl",
"curlie",
"wget",
"axel",
"aria2c",
"nc",
"telnet",
"lynx",
"w3m",
"links",
"httpie",
"xh",
"http-prompt",
"chrome",
"firefox",
"safari",
]
const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
@@ -30,12 +12,7 @@ export const BashTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z
.number()
.min(0)
.max(MAX_TIMEOUT)
.describe("Optional timeout in milliseconds")
.optional(),
timeout: z.number().min(0).max(MAX_TIMEOUT).describe("Optional timeout in milliseconds").optional(),
description: z
.string()
.describe(
@@ -44,11 +21,10 @@ export const BashTool = Tool.define({
}),
async execute(params, ctx) {
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
if (BANNED_COMMANDS.some((item) => params.command.startsWith(item)))
throw new Error(`Command '${params.command}' is not allowed`)
const process = Bun.spawn({
cmd: ["bash", "-c", params.command],
cwd: App.info().path.cwd,
maxBuffer: MAX_OUTPUT_LENGTH,
signal: ctx.abort,
timeout: timeout,
@@ -60,21 +36,14 @@ export const BashTool = Tool.define({
const stderr = await new Response(process.stderr).text()
return {
title: params.command,
metadata: {
stderr,
stdout,
exit: process.exitCode,
description: params.description,
title: params.command,
},
output: [
`<stdout>`,
stdout ?? "",
`</stdout>`,
`<stderr>`,
stderr ?? "",
`</stderr>`,
].join("\n"),
output: [`<stdout>`, stdout ?? "", `</stdout>`, `<stderr>`, stderr ?? "", `</stderr>`].join("\n"),
}
},
})
+366 -45
View File
@@ -1,12 +1,18 @@
// the approaches in this edit tool are sourced from
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
import { z } from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { LSP } from "../lsp"
import { createTwoFilesPatch } from "diff"
import { Permission } from "../permission"
import DESCRIPTION from "./edit.txt"
import { App } from "../app/app"
import { File } from "../file"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
export const EditTool = Tool.define({
id: "edit",
@@ -14,25 +20,20 @@ export const EditTool = Tool.define({
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z
.string()
.describe(
"The text to replace it with (must be different from old_string)",
),
replaceAll: z
.boolean()
.optional()
.describe("Replace all occurrences of old_string (default false)"),
newString: z.string().describe("The text to replace it with (must be different from old_string)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of old_string (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
}
if (params.oldString === params.newString) {
throw new Error("oldString and newString must be different")
}
const app = App.info()
const filepath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(app.path.cwd, params.filePath)
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
await Permission.ask({
id: "edit",
@@ -51,47 +52,30 @@ export const EditTool = Tool.define({
if (params.oldString === "") {
contentNew = params.newString
await Bun.write(filepath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
return
}
const file = Bun.file(filepath)
if (!(await file.exists())) throw new Error(`File ${filepath} not found`)
const stats = await file.stat()
if (stats.isDirectory())
throw new Error(`Path is a directory, not a file: ${filepath}`)
await FileTimes.assert(ctx.sessionID, filepath)
const stats = await file.stat().catch(() => {})
if (!stats) throw new Error(`File ${filepath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`)
await FileTime.assert(ctx.sessionID, filepath)
contentOld = await file.text()
const index = contentOld.indexOf(params.oldString)
if (index === -1)
throw new Error(
`oldString not found in file. Make sure it matches exactly, including whitespace and line breaks`,
)
if (params.replaceAll) {
contentNew = contentOld.replaceAll(params.oldString, params.newString)
}
if (!params.replaceAll) {
const lastIndex = contentOld.lastIndexOf(params.oldString)
if (index !== lastIndex)
throw new Error(
`oldString appears multiple times in the file. Please provide more context to ensure a unique match`,
)
contentNew =
contentOld.substring(0, index) +
params.newString +
contentOld.substring(index + params.oldString.length)
}
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
contentNew = await file.text()
})()
const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
FileTimes.read(ctx.sessionID, filepath)
FileTime.read(ctx.sessionID, filepath)
let output = ""
await LSP.touchFile(filepath, true)
@@ -109,13 +93,320 @@ export const EditTool = Tool.define({
metadata: {
diagnostics,
diff,
title: `${path.relative(app.path.root, filepath)}`,
},
title: `${path.relative(app.path.root, filepath)}`,
output,
}
},
})
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
}
export const LineTrimmedReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
break
}
}
if (matches) {
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length + 1
}
yield content.substring(matchStartIndex, matchEndIndex)
}
}
}
export const BlockAnchorReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines.length < 3) {
return
}
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
// Find blocks where first line matches the search first line
for (let i = 0; i < originalLines.length; i++) {
if (originalLines[i].trim() !== firstLineSearch) {
continue
}
// Look for the matching last line after this first line
for (let j = i + 2; j < originalLines.length; j++) {
if (originalLines[j].trim() === lastLineSearch) {
// Found a potential block from i to j
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k <= j - i; k++) {
matchEndIndex += originalLines[i + k].length
if (k < j - i) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
break // Only match the first occurrence of the last line
}
}
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(find)
// Handle single line matches
const lines = content.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (normalizeWhitespace(line) === normalizedFind) {
yield line
}
// Also check for substring matches within lines
const normalizedLine = normalizeWhitespace(line)
if (normalizedLine.includes(normalizedFind)) {
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
if (match) {
yield match[0]
}
} catch (e) {
// Invalid regex pattern, skip
}
}
}
}
// Handle multi-line matches
const findLines = find.split("\n")
if (findLines.length > 1) {
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length)
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
yield block.join("\n")
}
}
}
}
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
const removeIndentation = (text: string) => {
const lines = text.split("\n")
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
if (nonEmptyLines.length === 0) return text
const minIndent = Math.min(
...nonEmptyLines.map((line) => {
const match = line.match(/^(\s*)/)
return match ? match[1].length : 0
}),
)
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
}
const normalizedFind = removeIndentation(find)
const contentLines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
const block = contentLines.slice(i, i + findLines.length).join("\n")
if (removeIndentation(block) === normalizedFind) {
yield block
}
}
}
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
const unescapeString = (str: string): string => {
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
switch (capturedChar) {
case "n":
return "\n"
case "t":
return "\t"
case "r":
return "\r"
case "'":
return "'"
case '"':
return '"'
case "`":
return "`"
case "\\":
return "\\"
case "\n":
return "\n"
case "$":
return "$"
default:
return match
}
})
}
const unescapedFind = unescapeString(find)
// Try direct match with unescaped find string
if (content.includes(unescapedFind)) {
yield unescapedFind
}
// Also try finding escaped versions in content that match unescaped find
const lines = content.split("\n")
const findLines = unescapedFind.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
const unescapedBlock = unescapeString(block)
if (unescapedBlock === unescapedFind) {
yield block
}
}
}
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
// This replacer yields all exact matches, allowing the replace function
// to handle multiple occurrences based on replaceAll parameter
let startIndex = 0
while (true) {
const index = content.indexOf(find, startIndex)
if (index === -1) break
yield find
startIndex = index + find.length
}
}
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
const trimmedFind = find.trim()
if (trimmedFind === find) {
// Already trimmed, no point in trying
return
}
// Try to find the trimmed version
if (content.includes(trimmedFind)) {
yield trimmedFind
}
// Also try finding blocks where trimmed content matches
const lines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
if (block.trim() === trimmedFind) {
yield block
}
}
}
export const ContextAwareReplacer: Replacer = function* (content, find) {
const findLines = find.split("\n")
if (findLines.length < 3) {
// Need at least 3 lines to have meaningful context
return
}
// Remove trailing empty line if present
if (findLines[findLines.length - 1] === "") {
findLines.pop()
}
const contentLines = content.split("\n")
// Extract first and last lines as context anchors
const firstLine = findLines[0].trim()
const lastLine = findLines[findLines.length - 1].trim()
// Find blocks that start and end with the context anchors
for (let i = 0; i < contentLines.length; i++) {
if (contentLines[i].trim() !== firstLine) continue
// Look for the matching last line
for (let j = i + 2; j < contentLines.length; j++) {
if (contentLines[j].trim() === lastLine) {
// Found a potential context block
const blockLines = contentLines.slice(i, j + 1)
const block = blockLines.join("\n")
// Check if the middle content has reasonable similarity
// (simple heuristic: at least 50% of non-empty lines should match when trimmed)
if (blockLines.length === findLines.length) {
let matchingLines = 0
let totalNonEmptyLines = 0
for (let k = 1; k < blockLines.length - 1; k++) {
const blockLine = blockLines[k].trim()
const findLine = findLines[k].trim()
if (blockLine.length > 0 || findLine.length > 0) {
totalNonEmptyLines++
if (blockLine === findLine) {
matchingLines++
}
}
}
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
yield block
break // Only match the first occurrence
}
}
break
}
}
}
}
function trimDiff(diff: string): string {
const lines = diff.split("\n")
const contentLines = lines.filter(
@@ -151,3 +442,33 @@ function trimDiff(diff: string): string {
return trimmedLines.join("\n")
}
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
if (oldString === newString) {
throw new Error("oldString and newString must be different")
}
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
// EscapeNormalizedReplacer,
// TrimmedBoundaryReplacer,
// ContextAwareReplacer,
// MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
if (replaceAll) {
return content.replaceAll(search, newString)
}
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue
return content.substring(0, index) + newString + content.substring(index + search.length)
}
}
throw new Error("oldString not found in content or was found multiple times")
}
+4 -8
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Tool } from "./tool"
import { App } from "../app/app"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../external/ripgrep"
import { Ripgrep } from "../file/ripgrep"
export const GlobTool = Tool.define({
id: "glob",
@@ -20,9 +20,7 @@ export const GlobTool = Tool.define({
async execute(params) {
const app = App.info()
let search = params.path ?? app.path.cwd
search = path.isAbsolute(search)
? search
: path.resolve(app.path.cwd, search)
search = path.isAbsolute(search) ? search : path.resolve(app.path.cwd, search)
const limit = 100
const files = []
@@ -53,17 +51,15 @@ export const GlobTool = Tool.define({
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
)
output.push("(Results are truncated. Consider using a more specific path or pattern.)")
}
}
return {
title: path.relative(app.path.root, search),
metadata: {
count: files.length,
truncated,
title: path.relative(app.path.root, search),
},
output: output.join("\n"),
}
+10 -22
View File
@@ -1,7 +1,7 @@
import { z } from "zod"
import { Tool } from "./tool"
import { App } from "../app/app"
import { Ripgrep } from "../external/ripgrep"
import { Ripgrep } from "../file/ripgrep"
import DESCRIPTION from "./grep.txt"
@@ -9,21 +9,9 @@ export const GrepTool = Tool.define({
id: "grep",
description: DESCRIPTION,
parameters: z.object({
pattern: z
.string()
.describe("The regex pattern to search for in file contents"),
path: z
.string()
.optional()
.describe(
"The directory to search in. Defaults to the current working directory.",
),
include: z
.string()
.optional()
.describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
),
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
async execute(params) {
if (!params.pattern) {
@@ -51,7 +39,8 @@ export const GrepTool = Tool.define({
if (exitCode === 1) {
return {
metadata: { matches: 0, truncated: false, title: params.pattern },
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
@@ -93,7 +82,8 @@ export const GrepTool = Tool.define({
if (finalMatches.length === 0) {
return {
metadata: { matches: 0, truncated: false, title: params.pattern },
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
@@ -114,16 +104,14 @@ export const GrepTool = Tool.define({
if (truncated) {
outputLines.push("")
outputLines.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
)
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
}
return {
title: params.pattern,
metadata: {
matches: finalMatches.length,
truncated,
title: params.pattern,
},
output: outputLines.join("\n"),
}
+4 -13
View File
@@ -24,16 +24,8 @@ export const ListTool = Tool.define({
id: "list",
description: DESCRIPTION,
parameters: z.object({
path: z
.string()
.describe(
"The absolute path to the directory to list (must be absolute, not relative)",
)
.optional(),
ignore: z
.array(z.string())
.describe("List of glob patterns to ignore")
.optional(),
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params) {
const app = App.info()
@@ -44,8 +36,7 @@ export const ListTool = Tool.define({
for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
if (IGNORE_PATTERNS.some((p) => file.includes(p))) continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file))) continue
files.push(file)
if (files.length >= LIMIT) break
}
@@ -99,10 +90,10 @@ export const ListTool = Tool.define({
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(app.path.root, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
title: path.relative(app.path.root, searchPath),
},
output,
}
@@ -13,20 +13,16 @@ export const LspDiagnosticTool = Tool.define({
}),
execute: async (args) => {
const app = App.info()
const normalized = path.isAbsolute(args.path)
? args.path
: path.join(app.path.cwd, args.path)
const normalized = path.isAbsolute(args.path) ? args.path : path.join(app.path.cwd, args.path)
await LSP.touchFile(normalized, true)
const diagnostics = await LSP.diagnostics()
const file = diagnostics[normalized]
return {
title: path.relative(app.path.root, normalized),
metadata: {
diagnostics,
title: path.relative(app.path.root, normalized),
},
output: file?.length
? file.map(LSP.Diagnostic.pretty).join("\n")
: "No errors found",
output: file?.length ? file.map(LSP.Diagnostic.pretty).join("\n") : "No errors found",
}
},
})
+2 -9
View File
@@ -15,9 +15,7 @@ export const LspHoverTool = Tool.define({
}),
execute: async (args) => {
const app = App.info()
const file = path.isAbsolute(args.file)
? args.file
: path.join(app.path.cwd, args.file)
const file = path.isAbsolute(args.file) ? args.file : path.join(app.path.cwd, args.file)
await LSP.touchFile(file, true)
const result = await LSP.hover({
...args,
@@ -25,14 +23,9 @@ export const LspHoverTool = Tool.define({
})
return {
title: path.relative(app.path.root, file) + ":" + args.line + ":" + args.character,
metadata: {
result,
title:
path.relative(app.path.root, file) +
":" +
args.line +
":" +
args.character,
},
output: JSON.stringify(result, null, 2),
}
+2 -4
View File
@@ -10,9 +10,7 @@ export const MultiEditTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(EditTool.parameters)
.describe("Array of edit operations to perform sequentially on the file"),
edits: z.array(EditTool.parameters).describe("Array of edit operations to perform sequentially on the file"),
}),
async execute(params, ctx) {
const results = []
@@ -30,9 +28,9 @@ export const MultiEditTool = Tool.define({
}
const app = App.info()
return {
title: path.relative(app.path.root, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
title: path.relative(app.path.root, params.filePath),
},
output: results.at(-1)!.output,
}
+13 -40
View File
@@ -2,13 +2,11 @@ import { z } from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { FileTime } from "../file/time"
import DESCRIPTION from "./patch.txt"
const PatchParams = z.object({
patchText: z
.string()
.describe("The full patch text that describes all changes to be made"),
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
interface Change {
@@ -42,10 +40,7 @@ function identifyFilesNeeded(patchText: string): string[] {
const files: string[] = []
const lines = patchText.split("\n")
for (const line of lines) {
if (
line.startsWith("*** Update File:") ||
line.startsWith("*** Delete File:")
) {
if (line.startsWith("*** Update File:") || line.startsWith("*** Delete File:")) {
const filePath = line.split(":", 2)[1]?.trim()
if (filePath) files.push(filePath)
}
@@ -65,10 +60,7 @@ function identifyFilesAdded(patchText: string): string[] {
return files
}
function textToPatch(
patchText: string,
_currentFiles: Record<string, string>,
): [PatchOperation[], number] {
function textToPatch(patchText: string, _currentFiles: Record<string, string>): [PatchOperation[], number] {
const operations: PatchOperation[] = []
const lines = patchText.split("\n")
let i = 0
@@ -93,11 +85,7 @@ function textToPatch(
const changes: PatchChange[] = []
i++
while (
i < lines.length &&
!lines[i].startsWith("@@") &&
!lines[i].startsWith("***")
) {
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
const changeLine = lines[i]
if (changeLine.startsWith(" ")) {
changes.push({ type: "keep", content: changeLine.substring(1) })
@@ -151,10 +139,7 @@ function textToPatch(
return [operations, fuzz]
}
function patchToCommit(
operations: PatchOperation[],
currentFiles: Record<string, string>,
): Commit {
function patchToCommit(operations: PatchOperation[], currentFiles: Record<string, string>): Commit {
const changes: Record<string, Change> = {}
for (const op of operations) {
@@ -173,9 +158,7 @@ function patchToCommit(
const lines = originalContent.split("\n")
for (const hunk of op.hunks) {
const contextIndex = lines.findIndex((line) =>
line.includes(hunk.contextLine),
)
const contextIndex = lines.findIndex((line) => line.includes(hunk.contextLine))
if (contextIndex === -1) {
throw new Error(`Context line not found: ${hunk.contextLine}`)
}
@@ -204,11 +187,7 @@ function patchToCommit(
return { changes }
}
function generateDiff(
oldContent: string,
newContent: string,
filePath: string,
): [string, number, number] {
function generateDiff(oldContent: string, newContent: string, filePath: string): [string, number, number] {
// Mock implementation - would need actual diff generation
const lines1 = oldContent.split("\n")
const lines2 = newContent.split("\n")
@@ -244,7 +223,7 @@ export const PatchTool = Tool.define({
absPath = path.resolve(process.cwd(), absPath)
}
await FileTimes.assert(ctx.sessionID, absPath)
await FileTime.assert(ctx.sessionID, absPath)
try {
const stats = await fs.stat(absPath)
@@ -296,9 +275,7 @@ export const PatchTool = Tool.define({
// Process the patch
const [patch, fuzz] = textToPatch(params.patchText, currentFiles)
if (fuzz > 3) {
throw new Error(
`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
)
throw new Error(`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`)
}
// Convert patch to commit
@@ -343,26 +320,22 @@ export const PatchTool = Tool.define({
const newContent = change.new_content || ""
// Calculate diff statistics
const [, additions, removals] = generateDiff(
oldContent,
newContent,
filePath,
)
const [, additions, removals] = generateDiff(oldContent, newContent, filePath)
totalAdditions += additions
totalRemovals += removals
FileTimes.read(ctx.sessionID, absPath)
FileTime.read(ctx.sessionID, absPath)
}
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
const output = result
return {
title: `${filesToRead.length} files`,
metadata: {
changed: changedFiles,
additions: totalAdditions,
removals: totalRemovals,
title: `${filesToRead.length} files`,
},
output,
}
+12 -30
View File
@@ -3,7 +3,7 @@ import * as fs from "fs"
import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { FileTimes } from "./util/file-times"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { App } from "../app/app"
@@ -16,14 +16,8 @@ export const ReadTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The path to the file to read"),
offset: z
.number()
.describe("The line number to start reading from (0-based)")
.optional(),
limit: z
.number()
.describe("The number of lines to read (defaults to 2000)")
.optional(),
offset: z.number().describe("The line number to start reading from (0-based)").optional(),
limit: z.number().describe("The number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
let filePath = params.filePath
@@ -40,16 +34,13 @@ export const ReadTool = Tool.define({
const suggestions = dirEntries
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) ||
base.toLowerCase().includes(entry.toLowerCase()),
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
)
.map((entry) => path.join(dir, entry))
.slice(0, 3)
if (suggestions.length > 0) {
throw new Error(
`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
)
throw new Error(`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
}
throw new Error(`File not found: ${filePath}`)
@@ -57,21 +48,14 @@ export const ReadTool = Tool.define({
const stats = await file.stat()
if (stats.size > MAX_READ_SIZE)
throw new Error(
`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`,
)
throw new Error(`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`)
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset || 0
const isImage = isImageFile(filePath)
if (isImage)
throw new Error(
`This is an image file of type: ${isImage}\nUse a different tool to process images`,
)
if (isImage) throw new Error(`This is an image file of type: ${isImage}\nUse a different tool to process images`)
const lines = await file.text().then((text) => text.split("\n"))
const raw = lines.slice(offset, offset + limit).map((line) => {
return line.length > MAX_LINE_LENGTH
? line.substring(0, MAX_LINE_LENGTH) + "..."
: line
return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
})
const content = raw.map((line, index) => {
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
@@ -82,21 +66,19 @@ export const ReadTool = Tool.define({
output += content.join("\n")
if (lines.length > offset + content.length) {
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${
offset + content.length
})`
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${offset + content.length})`
}
output += "\n</file>"
// just warms the lsp client
await LSP.touchFile(filePath, true)
FileTimes.read(ctx.sessionID, filePath)
await LSP.touchFile(filePath, false)
FileTime.read(ctx.sessionID, filePath)
return {
title: path.relative(App.info().path.root, filePath),
output,
metadata: {
preview,
title: path.relative(App.info().path.root, filePath),
},
}
},
+2 -2
View File
@@ -2,12 +2,12 @@ Reads a file from the local filesystem. You can access any file directly by usin
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- The filePath parameter must be an absolute path, not a relative path
- By default, it reads up to 2000 lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any lines longer than 2000 characters will be truncated
- Results are returned using cat -n format, with line numbers starting at 1
- This tool allows OpenCode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as OpenCode is a multimodal LLM.
- This tool allows opencode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as opencode is a multimodal LLM.
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
+14 -19
View File
@@ -3,41 +3,36 @@ import DESCRIPTION from "./task.txt"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
import { Message } from "../session/message"
import { MessageV2 } from "../session/message-v2"
export const TaskTool = Tool.define({
id: "task",
description: DESCRIPTION,
parameters: z.object({
description: z
.string()
.describe("A short (3-5 words) description of the task"),
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
}),
async execute(params, ctx) {
const session = await Session.create(ctx.sessionID)
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
const metadata = msg.metadata.assistant!
const msg = (await Session.getMessage(ctx.sessionID, ctx.messageID)) as MessageV2.Assistant
function summary(input: Message.Info) {
function summary(input: MessageV2.Info) {
const result = []
for (const part of input.parts) {
if (part.type === "tool-invocation") {
result.push({
toolInvocation: part.toolInvocation,
metadata: input.metadata.tool[part.toolInvocation.toolCallId],
})
if (part.type === "tool" && part.state.status === "completed") {
result.push(part)
}
}
return result
}
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
if (evt.properties.info.metadata.sessionID !== session.id) return
const unsub = Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
if (evt.properties.info.sessionID !== session.id) return
ctx.metadata({
title: params.description,
summary: summary(evt.properties.info),
metadata: {
summary: summary(evt.properties.info),
},
})
})
@@ -46,8 +41,8 @@ export const TaskTool = Tool.define({
})
const result = await Session.chat({
sessionID: session.id,
modelID: metadata.modelID,
providerID: metadata.providerID,
modelID: msg.modelID,
providerID: msg.providerID,
parts: [
{
type: "text",
@@ -57,8 +52,8 @@ export const TaskTool = Tool.define({
})
unsub()
return {
title: params.description,
metadata: {
title: params.description,
summary: summary(result),
},
output: result.parts.findLast((x) => x.type === "text")!.text,
+4 -8
View File
@@ -5,12 +5,8 @@ import { App } from "../app/app"
const TodoInfo = z.object({
content: z.string().min(1).describe("Brief description of the task"),
status: z
.enum(["pending", "in_progress", "completed"])
.describe("Current status of the task"),
priority: z
.enum(["high", "medium", "low"])
.describe("Priority level of the task"),
status: z.enum(["pending", "in_progress", "completed", "cancelled"]).describe("Current status of the task"),
priority: z.enum(["high", "medium", "low"]).describe("Priority level of the task"),
id: z.string().describe("Unique identifier for the todo item"),
})
type TodoInfo = z.infer<typeof TodoInfo>
@@ -32,9 +28,9 @@ export const TodoWriteTool = Tool.define({
const todos = state()
todos[opts.sessionID] = params.todos
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
todos: params.todos,
},
}
@@ -48,9 +44,9 @@ export const TodoReadTool = Tool.define({
async execute(_params, opts) {
const todos = state()[opts.sessionID] ?? []
return {
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
metadata: {
todos,
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
},
output: JSON.stringify(todos, null, 2),
}
+6 -10
View File
@@ -2,19 +2,15 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"
export namespace Tool {
interface Metadata {
title: string
[key: string]: any
}
export type Context<M extends Metadata = Metadata> = {
sessionID: string
messageID: string
abort: AbortSignal
metadata(meta: M): void
metadata(input: { title?: string; metadata?: M }): void
}
export interface Info<
Parameters extends StandardSchemaV1 = StandardSchemaV1,
M extends Metadata = Metadata,
> {
export interface Info<Parameters extends StandardSchemaV1 = StandardSchemaV1, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
@@ -22,15 +18,15 @@ export namespace Tool {
args: StandardSchemaV1.InferOutput<Parameters>,
ctx: Context,
): Promise<{
title: string
metadata: M
output: string
}>
}
export function define<
Parameters extends StandardSchemaV1,
Result extends Metadata,
>(input: Info<Parameters, Result>): Info<Parameters, Result> {
export function define<Parameters extends StandardSchemaV1, Result extends Metadata>(
input: Info<Parameters, Result>,
): Info<Parameters, Result> {
return input
}
}
+17 -41
View File
@@ -14,9 +14,7 @@ export const WebFetchTool = Tool.define({
url: z.string().describe("The URL to fetch content from"),
format: z
.enum(["text", "markdown", "html"])
.describe(
"The format to return the content in (text, markdown, or html)",
),
.describe("The format to return the content in (text, markdown, or html)"),
timeout: z
.number()
.min(0)
@@ -26,17 +24,11 @@ export const WebFetchTool = Tool.define({
}),
async execute(params, ctx) {
// Validate URL
if (
!params.url.startsWith("http://") &&
!params.url.startsWith("https://")
) {
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
}
const timeout = Math.min(
(params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000,
MAX_TIMEOUT,
)
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
@@ -46,8 +38,7 @@ export const WebFetchTool = Tool.define({
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
})
@@ -79,16 +70,14 @@ export const WebFetchTool = Tool.define({
const text = await extractTextFromHTML(content)
return {
output: text,
metadata: {
title,
},
title,
metadata: {},
}
}
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
case "markdown":
@@ -96,32 +85,28 @@ export const WebFetchTool = Tool.define({
const markdown = convertHTMLToMarkdown(content)
return {
output: markdown,
metadata: {
title,
},
title,
metadata: {},
}
}
return {
output: "```\n" + content + "\n```",
metadata: {
title,
},
title,
metadata: {},
}
case "html":
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
default:
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
}
},
@@ -143,16 +128,7 @@ async function extractTextFromHTML(html: string) {
.on("*", {
element(element) {
// Reset skip flag when entering other elements
if (
![
"script",
"style",
"noscript",
"iframe",
"object",
"embed",
].includes(element.tagName)
) {
if (!["script", "style", "noscript", "iframe", "object", "embed"].includes(element.tagName)) {
skipContent = false
}
},
+12 -15
View File
@@ -1,39 +1,33 @@
import { z } from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { LSP } from "../lsp"
import { Permission } from "../permission"
import DESCRIPTION from "./write.txt"
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { FileTime } from "../file/time"
export const WriteTool = Tool.define({
id: "write",
description: DESCRIPTION,
parameters: z.object({
filePath: z
.string()
.describe(
"The absolute path to the file to write (must be absolute, not relative)",
),
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
}),
async execute(params, ctx) {
const app = App.info()
const filepath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(app.path.cwd, params.filePath)
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
const file = Bun.file(filepath)
const exists = await file.exists()
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
if (exists) await FileTime.assert(ctx.sessionID, filepath)
await Permission.ask({
id: "write",
sessionID: ctx.sessionID,
title: exists
? "Overwrite this file: " + filepath
: "Create new file: " + filepath,
title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath,
metadata: {
filePath: filepath,
content: params.content,
@@ -42,7 +36,10 @@ export const WriteTool = Tool.define({
})
await Bun.write(filepath, params.content)
FileTimes.read(ctx.sessionID, filepath)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
FileTime.read(ctx.sessionID, filepath)
let output = ""
await LSP.touchFile(filepath, true)
@@ -57,11 +54,11 @@ export const WriteTool = Tool.define({
}
return {
title: path.relative(app.path.root, filepath),
metadata: {
diagnostics,
filepath,
exists: exists,
title: path.relative(app.path.root, filepath),
},
output,
}
+1 -4
View File
@@ -7,10 +7,7 @@ export abstract class NamedError extends Error {
abstract schema(): ZodSchema
abstract toObject(): { name: string; data: any }
static create<Name extends string, Data extends ZodSchema>(
name: Name,
data: Data,
) {
static create<Name extends string, Data extends ZodSchema>(name: Name, data: Data) {
const schema = z
.object({
name: z.literal(name),
+24
View File
@@ -15,4 +15,28 @@ export namespace Filesystem {
}
return result
}
export async function globUp(pattern: string, start: string, stop?: string) {
let current = start
const result = []
while (true) {
try {
const glob = new Bun.Glob(pattern)
for await (const match of glob.scan({
cwd: current,
onlyFiles: true,
dot: true,
})) {
result.push(join(current, match))
}
} catch {
// Skip invalid glob patterns
}
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
}
}
+1 -1
View File
@@ -4,8 +4,8 @@ export function lazy<T>(fn: () => T) {
return (): T => {
if (loaded) return value as T
loaded = true
value = fn()
return value as T
}
}
+2 -8
View File
@@ -40,9 +40,7 @@ export namespace Log {
const filesToDelete = files.slice(0, -10)
await Promise.all(
filesToDelete.map((file) => fs.unlink(file).catch(() => {})),
)
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
}
let last = Date.now()
@@ -60,11 +58,7 @@ export namespace Log {
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return (
[next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message]
.filter(Boolean)
.join(" ") + "\n"
)
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result = {
info(message?: any, extra?: Record<string, any>) {
+14
View File
@@ -0,0 +1,14 @@
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timeout: NodeJS.Timeout
return Promise.race([
promise.then((result) => {
clearTimeout(timeout)
return result
}),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`Operation timed out after ${ms}ms`))
}, ms)
}),
])
}
+1 -1
View File
@@ -6,4 +6,4 @@
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
export {}
+342
View File
@@ -0,0 +1,342 @@
import { describe, expect, test } from "bun:test"
import { replace } from "../../src/tool/edit"
interface TestCase {
content: string
find: string
replace: string
all?: boolean
fail?: boolean
}
const testCases: TestCase[] = [
// SimpleReplacer cases
{
content: ["function hello() {", ' console.log("world");', "}"].join("\n"),
find: 'console.log("world");',
replace: 'console.log("universe");',
},
{
content: ["if (condition) {", " doSomething();", " doSomethingElse();", "}"].join("\n"),
find: [" doSomething();", " doSomethingElse();"].join("\n"),
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
},
// LineTrimmedReplacer cases
{
content: ["function test() {", ' console.log("hello");', "}"].join("\n"),
find: 'console.log("hello");',
replace: 'console.log("goodbye");',
},
{
content: ["const x = 5; ", "const y = 10;"].join("\n"),
find: "const x = 5;",
replace: "const x = 15;",
},
{
content: [" if (true) {", " return false;", " }"].join("\n"),
find: ["if (true) {", "return false;", "}"].join("\n"),
replace: ["if (false) {", "return true;", "}"].join("\n"),
},
// BlockAnchorReplacer cases
{
content: [
"function calculate(a, b) {",
" const temp = a + b;",
" const result = temp * 2;",
" return result;",
"}",
].join("\n"),
find: ["function calculate(a, b) {", " // different middle content", " return result;", "}"].join("\n"),
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join("\n"),
},
{
content: [
"class MyClass {",
" constructor() {",
" this.value = 0;",
" }",
" ",
" getValue() {",
" return this.value;",
" }",
"}",
].join("\n"),
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
replace: ["class MyClass {", " constructor() {", " this.value = 42;", " }", "}"].join("\n"),
},
// WhitespaceNormalizedReplacer cases
{
content: ["function test() {", '\tconsole.log("hello");', "}"].join("\n"),
find: ' console.log("hello");',
replace: ' console.log("world");',
},
{
content: "const x = 5;",
find: "const x = 5;",
replace: "const x = 10;",
},
{
content: "if\t( condition\t) {",
find: "if ( condition ) {",
replace: "if (newCondition) {",
},
// IndentationFlexibleReplacer cases
{
content: [" function nested() {", ' console.log("deeply nested");', " return true;", " }"].join(
"\n",
),
find: ["function nested() {", ' console.log("deeply nested");', " return true;", "}"].join("\n"),
replace: ["function nested() {", ' console.log("updated");', " return false;", "}"].join("\n"),
},
{
content: [" if (true) {", ' console.log("level 1");', ' console.log("level 2");', " }"].join("\n"),
find: ["if (true) {", 'console.log("level 1");', ' console.log("level 2");', "}"].join("\n"),
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
},
// replaceAll option cases
{
content: ['console.log("test");', 'console.log("test");', 'console.log("test");'].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: true,
},
{
content: ['console.log("test");', 'console.log("test");'].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: false,
},
// Error cases
{
content: 'console.log("hello");',
find: "nonexistent string",
replace: "updated",
fail: true,
},
{
content: ["test", "test", "different content", "test"].join("\n"),
find: "test",
replace: "updated",
all: false,
fail: true,
},
// Edge cases
{
content: "",
find: "",
replace: "new content",
},
{
content: "const regex = /[.*+?^${}()|[\\\\]\\\\\\\\]/g;",
find: "/[.*+?^${}()|[\\\\]\\\\\\\\]/g",
replace: "/\\\\w+/g",
},
{
content: 'const message = "Hello 世界! 🌍";',
find: "Hello 世界! 🌍",
replace: "Hello World! 🌎",
},
// EscapeNormalizedReplacer cases
{
content: 'console.log("Hello\nWorld");',
find: 'console.log("Hello\\nWorld");',
replace: 'console.log("Hello\nUniverse");',
},
{
content: "const str = 'It's working';",
find: "const str = 'It\\'s working';",
replace: "const str = 'It's fixed';",
},
{
content: "const template = `Hello ${name}`;",
find: "const template = `Hello \\${name}`;",
replace: "const template = `Hi ${name}`;",
},
{
content: "const path = 'C:\\Users\\test';",
find: "const path = 'C:\\\\Users\\\\test';",
replace: "const path = 'C:\\Users\\admin';",
},
// MultiOccurrenceReplacer cases (with replaceAll)
{
content: ["debug('start');", "debug('middle');", "debug('end');"].join("\n"),
find: "debug",
replace: "log",
all: true,
},
{
content: "const x = 1; const y = 1; const z = 1;",
find: "1",
replace: "2",
all: true,
},
// TrimmedBoundaryReplacer cases
{
content: [" function test() {", " return true;", " }"].join("\n"),
find: ["function test() {", " return true;", "}"].join("\n"),
replace: ["function test() {", " return false;", "}"].join("\n"),
},
{
content: "\n const value = 42; \n",
find: "const value = 42;",
replace: "const value = 24;",
},
{
content: ["", " if (condition) {", " doSomething();", " }", ""].join("\n"),
find: ["if (condition) {", " doSomething();", "}"].join("\n"),
replace: ["if (condition) {", " doNothing();", "}"].join("\n"),
},
// ContextAwareReplacer cases
{
content: [
"function calculate(a, b) {",
" const temp = a + b;",
" const result = temp * 2;",
" return result;",
"}",
].join("\n"),
find: [
"function calculate(a, b) {",
" // some different content here",
" // more different content",
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join("\n"),
},
{
content: [
"class TestClass {",
" constructor() {",
" this.value = 0;",
" }",
" ",
" method() {",
" return this.value;",
" }",
"}",
].join("\n"),
find: ["class TestClass {", " // different implementation", " // with multiple lines", "}"].join("\n"),
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join("\n"),
},
// Combined edge cases for new replacers
{
content: '\tconsole.log("test");\t',
find: 'console.log("test");',
replace: 'console.log("updated");',
},
{
content: [" ", "function test() {", " return 'value';", "}", " "].join("\n"),
find: ["function test() {", "return 'value';", "}"].join("\n"),
replace: ["function test() {", "return 'new value';", "}"].join("\n"),
},
// Test for same oldString and newString (should fail)
{
content: 'console.log("test");',
find: 'console.log("test");',
replace: 'console.log("test");',
fail: true,
},
// Additional tests for fixes made
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
{
content: 'const pattern = "test[123]";',
find: "test[123]",
replace: "test[456]",
},
{
content: 'const regex = "^start.*end$";',
find: "^start.*end$",
replace: "^begin.*finish$",
},
// EscapeNormalizedReplacer - test single backslash vs double backslash
{
content: 'const path = "C:\\Users";',
find: 'const path = "C:\\Users";',
replace: 'const path = "D:\\Users";',
},
{
content: 'console.log("Line1\\nLine2");',
find: 'console.log("Line1\\nLine2");',
replace: 'console.log("First\\nSecond");',
},
// BlockAnchorReplacer - test edge case with exact newline boundaries
{
content: ["function test() {", " return true;", "}"].join("\n"),
find: ["function test() {", " // middle", "}"].join("\n"),
replace: ["function test() {", " return false;", "}"].join("\n"),
},
// ContextAwareReplacer - test with trailing newline in find string
{
content: ["class Test {", " method1() {", " return 1;", " }", "}"].join("\n"),
find: [
"class Test {",
" // different content",
"}",
"", // trailing empty line
].join("\n"),
replace: ["class Test {", " method2() { return 2; }", "}"].join("\n"),
},
// Test validation for empty strings with same oldString and newString
{
content: "",
find: "",
replace: "",
fail: true,
},
// Test multiple occurrences with replaceAll=false (should fail)
{
content: ["const a = 1;", "const b = 1;", "const c = 1;"].join("\n"),
find: "= 1",
replace: "= 2",
all: false,
fail: true,
},
// Test whitespace normalization with multiple spaces and tabs mixed
{
content: "if\t \t( \tcondition\t )\t{",
find: "if ( condition ) {",
replace: "if (newCondition) {",
},
// Test escape sequences in template literals
{
content: "const msg = `Hello\\tWorld`;",
find: "const msg = `Hello\\tWorld`;",
replace: "const msg = `Hi\\tWorld`;",
},
]
describe("EditTool Replacers", () => {
test.each(testCases)("case %#", (testCase) => {
if (testCase.fail) {
expect(() => {
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
}).toThrow()
} else {
const result = replace(testCase.content, testCase.find, testCase.replace, testCase.all)
expect(result).toContain(testCase.replace)
}
})
})
+1 -4
View File
@@ -42,10 +42,7 @@ describe("tool.glob", () => {
describe("tool.ls", () => {
test("basic", async () => {
const result = await App.provide({ cwd: process.cwd() }, async () => {
return await ListTool.execute(
{ path: "./example", ignore: [".git"] },
ctx,
)
return await ListTool.execute({ path: "./example", ignore: [".git"] }, ctx)
})
expect(result.output).toMatchSnapshot()
})
+25
View File
@@ -0,0 +1,25 @@
# TUI Agent Guidelines
## Build/Test Commands
- **Build**: `go build ./cmd/opencode` (builds main binary)
- **Test**: `go test ./...` (runs all tests)
- **Single test**: `go test ./internal/theme -run TestLoadThemesFromJSON` (specific test)
- **Release build**: Uses `.goreleaser.yml` configuration
## Code Style
- **Language**: Go 1.24+ with standard formatting (`gofmt`)
- **Imports**: Group standard, third-party, local packages with blank lines
- **Naming**: Go conventions - PascalCase exports, camelCase private, ALL_CAPS constants
- **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping
- **Structs**: Define clear interfaces, embed when appropriate
- **Testing**: Use table-driven tests, `t.TempDir()` for file operations
## Architecture
- **TUI Framework**: Bubble Tea v2 with Lipgloss v2 for styling
- **Client**: Generated OpenAPI client communicates with TypeScript server
- **Components**: Reusable UI components in `internal/components/`
- **Themes**: JSON-based theming system with override hierarchy
- **State**: Centralized app state with message passing
+31 -18
View File
@@ -9,9 +9,12 @@ import (
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
flag "github.com/spf13/pflag"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode-sdk-go/option"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/pkg/client"
"golang.design/x/clipboard"
)
var Version = "dev"
@@ -22,10 +25,14 @@ func main() {
version = "v" + Version
}
var model *string = flag.String("model", "", "model to begin with")
var prompt *string = flag.String("prompt", "", "prompt to begin with")
flag.Parse()
url := os.Getenv("OPENCODE_SERVER")
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo client.AppInfo
var appInfo opencode.App
err := json.Unmarshal([]byte(appInfoStr), &appInfo)
if err != nil {
slog.Error("Failed to unmarshal app info", "error", err)
@@ -49,17 +56,29 @@ func main() {
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
httpClient, err := client.NewClientWithResponses(url)
slog.Debug("TUI launched", "app", appInfo)
httpClient := opencode.NewClient(
option.WithBaseURL(url),
)
if err != nil {
slog.Error("Failed to create client", "error", err)
os.Exit(1)
}
go func() {
err = clipboard.Init()
if err != nil {
slog.Error("Failed to initialize clipboard", "error", err)
}
}()
// Create main context for the application
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app_, err := app.New(ctx, version, appInfo, httpClient)
app_, err := app.New(ctx, version, appInfo, httpClient, model, prompt)
if err != nil {
panic(err)
}
@@ -71,21 +90,15 @@ func main() {
tea.WithMouseCellMotion(),
)
eventClient, err := client.NewClient(url)
if err != nil {
slog.Error("Failed to create event client", "error", err)
os.Exit(1)
}
evts, err := eventClient.Event(ctx)
if err != nil {
slog.Error("Failed to subscribe to events", "error", err)
os.Exit(1)
}
go func() {
for item := range evts {
program.Send(item)
stream := httpClient.Event.ListStreaming(ctx)
for stream.Next() {
evt := stream.Current().AsUnion()
program.Send(evt)
}
if err := stream.Err(); err != nil {
slog.Error("Error streaming events", "error", err)
program.Send(err)
}
}()
+25 -16
View File
@@ -10,20 +10,25 @@ require (
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
github.com/charmbracelet/x/ansi v0.8.0
github.com/google/uuid v1.6.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0
github.com/oapi-codegen/runtime v1.1.1
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
golang.design/x/clipboard v0.7.1
rsc.io/qr v0.2.0
)
replace github.com/sst/opencode-sdk-go => ./sdk
replace golang.design/x/clipboard v0.7.1 => github.com/sst/clipboard v0.0.0-20250708194754-36e9fc12a4e8
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
require (
dario.cat/mergo v1.0.2 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
@@ -35,7 +40,6 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
@@ -48,41 +52,46 @@ require (
github.com/sosodev/duration v1.3.1 // indirect
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/tools v0.31.0 // indirect
golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/tools v0.34.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/atotto/clipboard v0.1.4
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/disintegration/imaging v1.6.2
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.16
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rivo/uniseg v0.4.7
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/pflag v1.0.6
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/image v0.26.0
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0
golang.org/x/image v0.28.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.26.0
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+32 -28
View File
@@ -4,15 +4,12 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -23,7 +20,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
@@ -58,8 +54,6 @@ github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
@@ -110,7 +104,6 @@ github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -148,8 +141,6 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
@@ -190,14 +181,24 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/sst/clipboard v0.0.0-20250708194754-36e9fc12a4e8 h1:GNr7eAnMwAzUNk7eOsw4Flr9c9Uk55Wnf4840DCckwc=
github.com/sst/clipboard v0.0.0-20250708194754-36e9fc12a4e8/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
@@ -217,14 +218,17 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b h1:zELBzk+7ERc6m8BxhzU2VYjp03wlEvi+cIgYQR5H3CI=
golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8=
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -235,15 +239,15 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -262,28 +266,28 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+264 -211
View File
@@ -11,70 +11,70 @@ import (
"log/slog"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
"golang.design/x/clipboard"
)
var RootPath string
type App struct {
Info client.AppInfo
Version string
StatePath string
Config *client.ConfigInfo
Client *client.ClientWithResponses
State *config.State
Provider *client.ProviderInfo
Model *client.ModelInfo
Session *client.SessionInfo
Messages []client.MessageInfo
Commands commands.CommandRegistry
Info opencode.App
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *config.State
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.MessageUnion
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
compactCancel context.CancelFunc
}
type SessionSelectedMsg = *client.SessionInfo
type SessionSelectedMsg = *opencode.Session
type SessionLoadedMsg struct{}
type ModelSelectedMsg struct {
Provider client.ProviderInfo
Model client.ModelInfo
Provider opencode.Provider
Model opencode.Model
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendMsg struct {
Text string
Attachments []Attachment
}
type CompletionDialogTriggeredMsg struct {
InitialValue string
Attachments []opencode.FilePartParam
}
type OptimisticMessageAddedMsg struct {
Message client.MessageInfo
Message opencode.MessageUnion
}
type FileRenderedMsg struct {
FilePath string
}
func New(
ctx context.Context,
version string,
appInfo client.AppInfo,
httpClient *client.ClientWithResponses,
appInfo opencode.App,
httpClient *opencode.Client,
model *string,
prompt *string,
) (*App, error) {
RootPath = appInfo.Path.Root
util.RootPath = appInfo.Path.Root
util.CwdPath = appInfo.Path.Cwd
configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
configInfo, err := httpClient.Config.Get(ctx)
if err != nil {
return nil, err
}
if configResponse.StatusCode() != 200 || configResponse.JSON200 == nil {
return nil, fmt.Errorf("failed to get config: %d", configResponse.StatusCode())
}
configInfo := configResponse.JSON200
if configInfo.Keybinds == nil {
leader := "ctrl+x"
keybinds := client.ConfigKeybinds{
Leader: &leader,
}
configInfo.Keybinds = &keybinds
if configInfo.Keybinds.Leader == "" {
configInfo.Keybinds.Leader = "ctrl+x"
}
appStatePath := filepath.Join(appInfo.Path.State, "tui")
@@ -84,16 +84,16 @@ func New(
config.SaveState(appStatePath, appState)
}
if configInfo.Theme != nil {
appState.Theme = *configInfo.Theme
if configInfo.Theme != "" {
appState.Theme = configInfo.Theme
}
if configInfo.Model != nil {
splits := strings.Split(*configInfo.Model, "/")
if configInfo.Model != "" {
splits := strings.Split(configInfo.Model, "/")
appState.Provider = splits[0]
appState.Model = strings.Join(splits[1:], "/")
}
// Load themes from all directories
if err := theme.LoadThemesFromDirectories(
appInfo.Path.Config,
appInfo.Path.Root,
@@ -103,95 +103,154 @@ func New(
}
if appState.Theme != "" {
if appState.Theme == "system" && styles.Terminal != nil {
theme.UpdateSystemTheme(
styles.Terminal.Background,
styles.Terminal.BackgroundIsDark,
)
}
theme.SetTheme(appState.Theme)
}
slog.Debug("Loaded config", "config", configInfo)
app := &App{
Info: appInfo,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Commands: commands.LoadFromConfig(configInfo),
Info: appInfo,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
Session: &opencode.Session{},
Messages: []opencode.MessageUnion{},
Commands: commands.LoadFromConfig(configInfo),
InitialModel: model,
InitialPrompt: prompt,
}
return app, nil
}
func (a *App) Key(commandName commands.CommandName) string {
t := theme.CurrentTheme()
base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
muted := styles.NewStyle().
Background(t.Background()).
Foreground(t.TextMuted()).
Faint(true).
Render
command := a.Commands[commandName]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = a.Config.Keybinds.Leader + " " + kb.Key
}
return base(key) + muted(" "+command.Description)
}
func (a *App) SetClipboard(text string) tea.Cmd {
var cmds []tea.Cmd
cmds = append(cmds, func() tea.Msg {
clipboard.Write(clipboard.FmtText, []byte(text))
return nil
})
// try to set the clipboard using OSC52 for terminals that support it
cmds = append(cmds, tea.SetClipboard(text))
return tea.Sequence(cmds...)
}
func (a *App) InitializeProvider() tea.Cmd {
return func() tea.Msg {
providersResponse, err := a.Client.PostProviderListWithResponse(context.Background())
if err != nil {
slog.Error("Failed to list providers", "error", err)
// TODO: notify user
return nil
}
if providersResponse != nil && providersResponse.StatusCode() != 200 {
slog.Error("failed to retrieve providers", "status", providersResponse.StatusCode(), "message", string(providersResponse.Body))
return nil
}
providers := []client.ProviderInfo{}
var defaultProvider *client.ProviderInfo
var defaultModel *client.ModelInfo
providersResponse, err := a.Client.Config.Providers(context.Background())
if err != nil {
slog.Error("Failed to list providers", "error", err)
// TODO: notify user
return nil
}
providers := providersResponse.Providers
var defaultProvider *opencode.Provider
var defaultModel *opencode.Model
var anthropic *client.ProviderInfo
for _, provider := range providersResponse.JSON200.Providers {
if provider.Id == "anthropic" {
anthropic = &provider
var anthropic *opencode.Provider
for _, provider := range providers {
if provider.ID == "anthropic" {
anthropic = &provider
}
}
// default to anthropic if available
if anthropic != nil {
defaultProvider = anthropic
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
for _, provider := range providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
}
providers = append(providers, provider)
}
if len(providers) == 0 {
slog.Error("No providers configured")
return nil
}
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
if provider.ID == a.State.Provider {
currentProvider = &provider
for _, model := range provider.Models {
if model.ID == a.State.Model {
currentModel = &model
}
}
}
}
if currentProvider == nil || currentModel == nil {
currentProvider = defaultProvider
currentModel = defaultModel
}
// default to anthropic if available
if anthropic != nil {
defaultProvider = anthropic
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
for _, provider := range providersResponse.JSON200.Providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
}
providers = append(providers, provider)
}
if len(providers) == 0 {
slog.Error("No providers configured")
return nil
}
var currentProvider *client.ProviderInfo
var currentModel *client.ModelInfo
var initialProvider *opencode.Provider
var initialModel *opencode.Model
if a.InitialModel != nil && *a.InitialModel != "" {
splits := strings.Split(*a.InitialModel, "/")
for _, provider := range providers {
if provider.Id == a.State.Provider {
currentProvider = &provider
if provider.ID == splits[0] {
initialProvider = &provider
for _, model := range provider.Models {
if model.Id == a.State.Model {
currentModel = &model
modelID := strings.Join(splits[1:], "/")
if model.ID == modelID {
initialModel = &model
}
}
}
}
if currentProvider == nil || currentModel == nil {
currentProvider = defaultProvider
currentModel = defaultModel
}
// TODO: handle no provider or model setup, yet
return ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
}
}
if initialProvider != nil && initialModel != nil {
currentProvider = initialProvider
currentModel = initialModel
}
var cmds []tea.Cmd
cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
}))
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
}
return tea.Sequence(cmds...)
}
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
if match, ok := response.JSON200.Default[provider.Id]; ok {
func getDefaultModel(
response *opencode.ConfigProvidersResponse,
provider opencode.Provider,
) *opencode.Model {
if match, ok := response.Default[provider.ID]; ok {
model := provider.Models[match]
return &model
} else {
@@ -202,20 +261,16 @@ func getDefaultModel(response *client.PostProviderListResponse, provider client.
return nil
}
type Attachment struct {
FilePath string
FileName string
MimeType string
Content []byte
}
func (a *App) IsBusy() bool {
if len(a.Messages) == 0 {
return false
}
lastMessage := a.Messages[len(a.Messages)-1]
return lastMessage.Metadata.Time.Completed == nil
if casted, ok := lastMessage.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0
}
return false
}
func (a *App) SaveState() {
@@ -238,96 +293,99 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
go func() {
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
_, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
if err != nil {
slog.Error("Failed to initialize project", "error", err)
// status.Error(err.Error())
}
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to initialize project", "error", response.StatusCode)
// status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
}
}()
return tea.Batch(cmds...)
}
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
if a.compactCancel != nil {
a.compactCancel()
}
compactCtx, cancel := context.WithCancel(ctx)
a.compactCancel = cancel
go func() {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
defer func() {
a.compactCancel = nil
}()
_, err := a.Client.Session.Summarize(compactCtx, a.Session.ID, opencode.SessionSummarizeParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
if err != nil {
slog.Error("Failed to compact session", "error", err)
}
if response != nil && response.StatusCode() != 200 {
slog.Error("Failed to compact session", "error", response.StatusCode)
if compactCtx.Err() != context.Canceled {
slog.Error("Failed to compact session", "error", err)
}
}
}()
return nil
}
func (a *App) MarkProjectInitialized(ctx context.Context) error {
response, err := a.Client.PostAppInitialize(ctx)
_, err := a.Client.App.Init(ctx)
if err != nil {
slog.Error("Failed to mark project as initialized", "error", err)
return err
}
if response != nil && response.StatusCode != 200 {
return fmt.Errorf("failed to initialize project: %d", response.StatusCode)
}
return nil
}
func (a *App) CreateSession(ctx context.Context) (*client.SessionInfo, error) {
resp, err := a.Client.PostSessionCreateWithResponse(ctx)
func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
session, err := a.Client.Session.New(ctx)
if err != nil {
return nil, err
}
if resp != nil && resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to create session: %d", resp.StatusCode())
}
session := resp.JSON200
return session, nil
}
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
func (a *App) SendChatMessage(
ctx context.Context,
text string,
attachments []opencode.FilePartParam,
) (*App, tea.Cmd) {
var cmds []tea.Cmd
if a.Session.Id == "" {
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
if err != nil {
return toast.NewErrorToast(err.Error())
return a, toast.NewErrorToast(err.Error())
}
a.Session = session
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
}
part := client.MessagePart{}
part.FromMessagePartText(client.MessagePartText{
Type: "text",
optimisticParts := []opencode.UserMessagePart{{
Type: opencode.UserMessagePartTypeText,
Text: text,
})
parts := []client.MessagePart{part}
}}
if len(attachments) > 0 {
for _, attachment := range attachments {
optimisticParts = append(optimisticParts, opencode.UserMessagePart{
Type: opencode.UserMessagePartTypeFile,
Filename: attachment.Filename.Value,
Mime: attachment.Mime.Value,
URL: attachment.URL.Value,
})
}
}
optimisticMessage := client.MessageInfo{
Id: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: client.User,
Parts: parts,
Metadata: client.MessageMetadata{
SessionID: a.Session.Id,
Time: struct {
Completed *float32 `json:"completed,omitempty"`
Created float32 `json:"created"`
}{
Created: float32(time.Now().Unix()),
},
Tool: make(map[string]client.MessageMetadata_Tool_AdditionalProperties),
optimisticMessage := opencode.UserMessage{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.UserMessageRoleUser,
Parts: optimisticParts,
SessionID: a.Session.ID,
Time: opencode.UserMessageTime{
Created: float64(time.Now().Unix()),
},
}
@@ -335,108 +393,103 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
cmds = append(cmds, func() tea.Msg {
response, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{
SessionID: a.Session.Id,
Parts: parts,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
parts := []opencode.UserMessagePartUnionParam{
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
},
}
if len(attachments) > 0 {
for _, attachment := range attachments {
parts = append(parts, opencode.FilePartParam{
Mime: attachment.Mime,
Type: attachment.Type,
URL: attachment.URL,
Filename: attachment.Filename,
})
}
}
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
Parts: opencode.F(parts),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
if err != nil {
errormsg := fmt.Sprintf("failed to send message: %v", err)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
if response != nil && response.StatusCode != 200 {
errormsg := fmt.Sprintf("failed to send message: %d", response.StatusCode)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
return nil
})
// The actual response will come through SSE
// For now, just return success
return tea.Batch(cmds...)
return a, tea.Batch(cmds...)
}
func (a *App) Cancel(ctx context.Context, sessionID string) error {
response, err := a.Client.PostSessionAbort(ctx, client.PostSessionAbortJSONRequestBody{
SessionID: sessionID,
})
// Cancel any running compact operation
if a.compactCancel != nil {
a.compactCancel()
a.compactCancel = nil
}
_, err := a.Client.Session.Abort(ctx, sessionID)
if err != nil {
slog.Error("Failed to cancel session", "error", err)
// status.Error(err.Error())
return err
}
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to cancel session", "error", fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
// status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
}
return nil
}
func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
resp, err := a.Client.PostSessionListWithResponse(ctx)
func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
response, err := a.Client.Session.List(ctx)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
if response == nil {
return []opencode.Session{}, nil
}
if resp.JSON200 == nil {
return []client.SessionInfo{}, nil
}
sessions := *resp.JSON200
sessions := *response
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].Time.Created-sessions[j].Time.Created > 0
})
return sessions, nil
}
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{
SessionID: sessionID,
})
_, err := a.Client.Session.Delete(ctx, sessionID)
if err != nil {
slog.Error("Failed to delete session", "error", err)
return err
}
if resp.StatusCode() != 200 {
return fmt.Errorf("failed to delete session: %d", resp.StatusCode())
}
return nil
}
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) {
resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId})
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) {
response, err := a.Client.Session.Messages(ctx, sessionId)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list messages: %d", resp.StatusCode())
if response == nil {
return []opencode.Message{}, nil
}
if resp.JSON200 == nil {
return []client.MessageInfo{}, nil
}
messages := *resp.JSON200
messages := *response
return messages, nil
}
func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error) {
resp, err := a.Client.PostProviderListWithResponse(ctx)
func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
response, err := a.Client.Config.Providers(ctx)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
}
if resp.JSON200 == nil {
return []client.ProviderInfo{}, nil
if response == nil {
return []opencode.Provider{}, nil
}
providers := *resp.JSON200
providers := *response
return providers.Providers, nil
}

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