Compare commits

...

374 Commits

Author SHA1 Message Date
Dax Raad 2142c34fdd add auto formatting and experimental hooks feature
publish / publish (push) Waiting to run
2025-06-26 22:17:08 -04:00
Jay V 07418353dc docs: lander tweak 2025-06-26 19:47:58 -04:00
Jay V f318800d1c docs: tweak lander 2025-06-26 19:02:44 -04:00
Dax Raad bcc5b62ab1 disable task tool temporarily
publish / publish (push) Waiting to run
2025-06-26 18:27:49 -04:00
Jay V 6c8b7ef436 docs: edit theme doc 2025-06-26 17:56:31 -04:00
Dax Raad 7b3a84ec23 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 81ec62671c Merge branch 'rekram1-node-chore/update-config-docs' into dev 2025-06-26 17:24:03 -04:00
Jay V e445a8f1e3 docs: editing rules 2025-06-26 17:23:52 -04:00
adamdottv dd739a913c fix(tui): editor styles were off 2025-06-26 17:22:21 -04:00
adamdottv 0beabc75c5 feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottv cdd24efe28 feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottv 74980357f7 fix(tui): editor width issues 2025-06-26 17:22:21 -04:00
adamdottv e7dcb3a646 feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 17:22:21 -04:00
adamdottv 82b8777f73 fix(tui): chat editor aesthetics 2025-06-26 17:22:21 -04:00
adamdottv 8c6ead66e6 fix(tui): visual issue with modal selected items in system theme 2025-06-26 17:22:21 -04:00
adamdottv e152ab1c3e chore: tui agents.md 2025-06-26 17:22:21 -04:00
Jay V 6b0bc17f55 docs: social share 2025-06-26 17:22:17 -04:00
adamdottv d0630ffbce fix(tui): editor spinner colors 2025-06-26 17:21:53 -04:00
Jay V f91e6e39a6 docs: share page responsive diff 2025-06-26 17:21:53 -04:00
adamdottv f4d718e26f docs: system theme 2025-06-26 17:21:53 -04:00
adamdottv f3b7b59a99 fix(tui): make opencode theme default 2025-06-26 17:21:53 -04:00
Adam 2267e4dda2 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 f038bf694a Fix undefined is not an object (evaluating 'G.title') (#395) 2025-06-26 17:21:53 -04:00
Juhani Pelli b36c9ef4f2 fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-26 17:21:53 -04:00
Dax Raad 72cf101b3f more edit tool fixes 2025-06-26 17:21:53 -04:00
Dax Raad eed50647c4 integrate gemini-cli strategies for edit tool 2025-06-26 17:21:53 -04:00
Dax Raad 0c4ed45f75 make edit tool more robust 2025-06-26 17:21:53 -04:00
Dax Raad 09c1c5cd41 ignore: typo 2025-06-26 17:21:53 -04:00
Dax Raad 47853d656c add output length errors 2025-06-26 17:21:53 -04:00
Dax Raad 8c71b48b62 fix codex not working 2025-06-26 17:21:53 -04:00
Dax Raad 14033dcc3c redirect uncaught errors to log file 2025-06-26 17:21:53 -04:00
adamdottv c3b68acf50 fix(tui): editor styles were off
publish / publish (push) Waiting to run
2025-06-26 15:12:26 -05:00
adamdottv b9ad7efbaf feat(tui): more themes
publish / publish (push) Waiting to run
2025-06-26 15:03:30 -05:00
adamdottv 499c8753b7 feat(tui): more themes 2025-06-26 14:54:32 -05:00
adamdottv f6554bfd19 fix(tui): editor width issues
publish / publish (push) Waiting to run
2025-06-26 12:57:11 -05:00
adamdottv 912ef162a0 feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 12:47:17 -05:00
adamdottv baeee448a8 fix(tui): chat editor aesthetics 2025-06-26 12:44:44 -05:00
adamdottv 6e84d1d671 fix(tui): visual issue with modal selected items in system theme
publish / publish (push) Waiting to run
2025-06-26 12:33:06 -05:00
adamdottv 95aaa6ded5 chore: tui agents.md 2025-06-26 12:28:29 -05:00
Jay V 44e85db4ec docs: social share 2025-06-26 13:21:42 -04:00
adamdottv c05b3e86fd fix(tui): editor spinner colors 2025-06-26 12:21:20 -05:00
Jay V dd9d55f07a docs: share page responsive diff 2025-06-26 13:06:41 -04:00
adamdottv 8a77348fd9 docs: system theme 2025-06-26 11:33:02 -05:00
adamdottv a07424a36e fix(tui): make opencode theme default 2025-06-26 11:32:25 -05:00
Adam 069d4b4c2c feat: default system theme (#419)
publish / publish (push) Waiting to run
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 10:16:07 -05:00
rekram1-node 7188551854 chore: document AGENTS.md 2025-06-26 08:28:06 -05:00
Mike Wallio 8ed884557e Fix undefined is not an object (evaluating 'G.title') (#395)
publish / publish (push) Waiting to run
2025-06-25 19:40:09 -04:00
Juhani Pelli 4220d272b2 fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-25 19:39:51 -04:00
Dax Raad 19c4e09dd2 more edit tool fixes
publish / publish (push) Waiting to run
2025-06-25 19:22:54 -04:00
Dax Raad 04f770b57e integrate gemini-cli strategies for edit tool
publish / publish (push) Waiting to run
2025-06-25 17:56:14 -04:00
Dax Raad 1227651bbc make edit tool more robust
publish / publish (push) Waiting to run
2025-06-25 17:10:48 -04:00
Dax Raad e82ff0e159 ignore: typo 2025-06-25 11:02:57 -04:00
Dax Raad 56050bca9c add output length errors 2025-06-25 11:02:09 -04:00
Dax Raad e221442b6b fix codex not working
publish / publish (push) Waiting to run
2025-06-25 10:01:35 -04:00
Dax Raad bf39e0ac01 redirect uncaught errors to log file
publish / publish (push) Waiting to run
2025-06-25 08:41:10 -04:00
Dax Raad 53e90f2764 better error message when bad directory is specified to start in 2025-06-24 22:28:25 -04:00
Dax Raad d333addc69 Enhance auth command with environment variable display and add models command
publish / publish (push) Waiting to run
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-24 22:24:55 -04:00
Dax Raad 2461ee70d8 do not print error on ctrl+c during prompts 2025-06-24 22:09:43 -04:00
Dax Raad 05182debe9 ci: bun lock 2025-06-24 21:14:32 -04:00
Dax Raad 605b2f891f ci: fix type issue 2025-06-24 21:12:32 -04:00
Dax Raad cbcef488f6 remove accidental opanai autoloader
publish / publish (push) Waiting to run
2025-06-24 21:11:11 -04:00
Dax Raad c841e0a1cc added opencode serve command
publish / publish (push) Waiting to run
2025-06-24 20:52:09 -04:00
Jay V 3a4969c8b1 docs: share page styles 2025-06-24 19:34:35 -04:00
Jay V 24a1399fa0 docs: share page bash tool output 2025-06-24 19:28:51 -04:00
Aiden Cline 245f14dfda fix: file completions replaced wrong text when paths overlap (#378) 2025-06-24 18:13:15 -05:00
Dax Raad 171289e420 docs: windows instructions
publish / publish (push) Waiting to run
2025-06-24 18:54:59 -04:00
Dax Raad bfa4c87379 ci: windows 2025-06-24 18:46:43 -04:00
Dax Raad eb6e0d92cc ci: windows 2025-06-24 18:40:36 -04:00
Dax Raad 3dea857589 ci: windows 2025-06-24 18:27:57 -04:00
Dax Raad f6862be398 ci: windows 2025-06-24 18:13:15 -04:00
Dax ad32e5975a windows fixes (#374)
Co-authored-by: Matthew Glazar <strager.nds@gmail.com>
2025-06-24 18:05:04 -04:00
Jay 440ce260df docs: Share page diff view improvements (#373) 2025-06-24 17:11:43 -04:00
Dax Raad f1fe3b6826 ignore: revert
publish / publish (push) Waiting to run
2025-06-24 14:59:27 -04:00
Dax Raad 0b96daf6b7 ci: upgrade bun 2025-06-24 14:42:25 -04:00
Dax Raad 5849b8b9d3 potential fix for failing to install provider package on first run 2025-06-24 14:33:35 -04:00
Jay V 609ca542b7 docs: share code blocks in markdown 2025-06-24 13:53:59 -04:00
Adam b0b9740de9 feat: delete sessions (#362)
publish / publish (push) Waiting to run
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 11:07:41 -05:00
Dax Raad 96e0f3ffc3 remove debug code writing to /tmp/message.json
publish / publish (push) Waiting to run
2025-06-24 11:16:17 -04:00
Dax Raad 44fa5e5efe ignore: fix type errors 2025-06-24 11:09:36 -04:00
Dax Raad e54520ba82 externalize github copilot code 2025-06-24 10:42:19 -04:00
Tom 777645ea9b feat(tui): add debounce logic to escape key interrupt (#169)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 06:31:02 -05:00
Dax Raad d4e82228b4 add HOMEBREW_NO_AUTO_UPDATE to brew upgrades
publish / publish (push) Waiting to run
2025-06-23 20:36:08 -04:00
Dax Raad 17f7c8c131 force use npm registry
publish / publish (push) Waiting to run
2025-06-23 20:23:37 -04:00
GitMurf 72e44a4c84 fix: typescript error (any) from models (#347) 2025-06-23 18:44:57 -04:00
Dax Raad d7b0155b31 glob tool should respect .gitignore
publish / publish (push) Waiting to run
2025-06-23 17:37:32 -04:00
Dax Raad 1894d4b014 bundle models.dev at build time and ignore refresh errors
publish / publish (push) Waiting to run
2025-06-23 14:50:19 -04:00
Dax Raad 4f5d8bba8e ignore: remove opencode.json 2025-06-23 14:32:57 -04:00
Lucas Grzegorczyk 151861f640 Fix project folder name starting with "-" in data (#323). Note old session data will still be in the old format in ~/.local/share/opencode/projects - you can remove the leading dash to recover the, 2025-06-23 14:31:51 -04:00
adamdottv 4250f7803b feat(tui): optimistically render user messages
publish / publish (push) Waiting to run
2025-06-23 12:30:20 -05:00
Jay V c550376116 fix help copy 2025-06-23 13:00:24 -04:00
adamdottv 26fa010891 fix(tui): less incorrect escapingn of < and > 2025-06-23 11:32:32 -05:00
adamdottv fe57afac6b fix: overlay border color issues 2025-06-23 11:21:49 -05:00
adamdottv 2674e9f6dd fix: markdown wrapping issue 2025-06-23 11:20:44 -05:00
adamdottv 98dc5bd3d3 chore: unused import 2025-06-23 10:21:57 -05:00
adamdottv b94a248a0f docs: disabled_providers 2025-06-23 10:21:25 -05:00
GitMurf 9c280d0852 feat(copilot): edit headers for better rate limit avoidance (#321) 2025-06-23 10:44:19 -04:00
Dax Raad 64e72f1eec more graceful mcp failures
publish / publish (push) Waiting to run
2025-06-22 21:10:05 -04:00
Dax Raad 20d06c6173 aws autoload include more env vars 2025-06-22 20:16:10 -04:00
Dax Raad daa89f2e11 fix double entry of github copilot in auth login
publish / publish (push) Waiting to run
2025-06-22 19:13:25 -04:00
Martin Palma b172f79404 Add Github Copilot OAuth authentication flow (#305) 2025-06-22 19:11:37 -04:00
Dax Raad f7c32c6b06 docs: contributing 2025-06-22 17:55:10 -04:00
adamdottv b874ff0888 fix(tui): color contrast fixes for nord 2025-06-22 15:17:18 -05:00
adamdottv 5df7b580c7 fix(tui): help dialog visuals 2025-06-22 14:28:16 -05:00
Márk Magyar a90cb9f298 ignore: fix typos and formatting (#294) 2025-06-22 14:26:46 -04:00
Tom 81f31481e0 fix: resolve test failures by adding missing zod-openapi import (#301)
Co-authored-by: opencode <noreply@opencode.ai>
2025-06-22 14:25:02 -04:00
Tom aa58c7de3a combine stdout and stderr in bash tool output (#300)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-22 14:24:35 -04:00
Adam b654ce47ea fix(tui): mouse wheel escape codes leaking into input 2025-06-22 10:26:44 -05:00
adamdottv a157edb81f fix(tui): cleanup help dialog
publish / publish (push) Waiting to run
2025-06-22 06:44:23 -05:00
phantomreactor 129469a108 fix: invisible html tags and compact long delay (#304) 2025-06-22 06:29:04 -05:00
adamdottv 31d643736c fix(tui): cleanup modal visuals
publish / publish (push) Waiting to run
2025-06-22 06:09:23 -05:00
adamdottv 96252dbadc fix(tui): theme dialog visuals 2025-06-22 05:34:22 -05:00
Márk Magyar 63499e156f fix: session title generation (#293) 2025-06-21 14:32:11 -05:00
tyrellshawn 3826151828 Created a Theme inspired by the matrix (#285) 2025-06-21 07:29:49 -05:00
Thomas Meire 0ff3821efa Add error handling on the calls to the server to debug issue #132 (#137) 2025-06-21 07:24:39 -05:00
Dax Raad 0ae21cb235 footer clarifies it's showing context usage, not input token usage 2025-06-20 22:52:51 -04:00
Dax Raad 5cc23eeffa huge optimization for token usage with anthropic
publish / publish (push) Waiting to run
2025-06-20 22:43:04 -04:00
Dax Raad b11e2a5f57 Add --method option to upgrade command for manual installation method selection
publish / publish (push) Waiting to run
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-20 20:48:23 -04:00
Dax Raad 04896da98c handle brew upgrades better 2025-06-20 20:27:23 -04:00
Dax Raad 4a59893f32 make IDs more random
publish / publish (push) Waiting to run
2025-06-20 17:39:59 -04:00
Saatvik Arya 222e9d686e docs: add API client generation instructions to README and AGENTS.md (#273) 2025-06-20 17:27:58 -04:00
Saatvik Arya 9f590063f6 fix: return false for missing AWS_PROFILE in amazon-bedrock provider (#277) 2025-06-20 17:27:27 -04:00
adamdottv f4c59e865a fix(tui): visual tweaks to themes
publish / publish (push) Waiting to run
2025-06-20 15:49:51 -05:00
adamdottv c1396302f8 docs: cleanup casing
publish / publish (push) Waiting to run
2025-06-20 15:35:25 -05:00
adamdottv b24299267e docs: new themes docs 2025-06-20 15:31:38 -05:00
adamdottv ac66652df1 feat(tui): one dark theme 2025-06-20 15:14:23 -05:00
adamdottv 74b025a4ea feat(tui): kanagawa theme 2025-06-20 15:14:23 -05:00
adamdottv bd68b08aac feat(tui): gruvbox theme 2025-06-20 15:14:23 -05:00
adamdottv ef51cb8c70 feat(tui): catppuccin theme 2025-06-20 15:14:22 -05:00
adamdottv c5ea952100 feat(tui): nord theme 2025-06-20 15:14:22 -05:00
adamdottv 4e3dc82ea4 feat(tui): custom themes 2025-06-20 15:14:22 -05:00
Márk Magyar 843f831497 feat(tui): theme switcher with preview (#264) 2025-06-20 15:14:05 -05:00
Jay V f0af9b3296 docs: share bugs 2025-06-20 15:50:12 -04:00
Jay V 024d093b2f docs: share page bugs 2025-06-20 15:50:12 -04:00
Dax Raad 9632a32ecd fix issue with conversations hanging 2025-06-20 15:49:49 -04:00
Dax Raad 1e9fa8d022 docs: readme 2025-06-20 15:22:41 -04:00
Dax Raad 4ec02330f3 Consolidate session context handling and add global config support
publish / publish (push) Waiting to run
Refactored context file discovery by removing separate SessionContext module and integrating functionality into SystemPrompt.context(). Added support for finding AGENTS.md and CLAUDE.md files in global config directories.

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-20 15:14:12 -04:00
Josh a3c73c4322 feat: forward provider options from model config (#202)
publish / publish (push) Waiting to run
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-20 15:03:41 -04:00
Dmytro Yankovskyi 0419533097 fix(#243): claude on aws bedrock (#241)
publish / publish (push) Waiting to run
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-20 14:57:33 -04:00
niba b28abc7b6c chore: use client_id everywhere (#260) 2025-06-20 14:56:33 -04:00
adamdottv ef298f3e9e docs: theme schema update 2025-06-20 13:51:32 -05:00
adamdottv e1a186f869 docs: theme schema 2025-06-20 13:00:32 -05:00
adamdottv fdbdf1d281 chore(tui): rename theme colors 2025-06-20 13:00:31 -05:00
Jay V 2746ce1673 docs: add config 2025-06-20 13:22:31 -04:00
Jay V 91b4ee0c15 docs: share header 2025-06-20 13:12:35 -04:00
Jay V c0533a86ab docs: test 2025-06-20 13:02:05 -04:00
Jay V b86828ded7 docs: test 2025-06-20 12:59:32 -04:00
Jay V 7f3362a773 docs: test 2025-06-20 12:56:00 -04:00
adamdottv 65dcb33041 fix(tui): async load messages on theme/session switch 2025-06-20 11:25:21 -05:00
adamdottv 02ec438865 fix(tui): input latency optimization 2025-06-20 11:08:08 -05:00
adamdottv 8bb4f64b0b fix(tui): fetch tool more defensive
publish / publish (push) Waiting to run
2025-06-20 09:00:28 -05:00
adamdottv bddfa6557b fix(tui): typeahead open/close perf 2025-06-20 08:20:10 -05:00
adamdottv e57ef002ee chore: fix typescript issues 2025-06-20 07:48:42 -05:00
adamdottv c90a1a4467 fix(tui): insert newline correctly positioned 2025-06-20 07:42:04 -05:00
adamdottv 0b7358672e fix(tui): input feels laggy 2025-06-20 07:31:45 -05:00
Dax Raad 76cd9dc4b1 docs: remove opencode.json 2025-06-20 01:00:15 -04:00
Dax Raad cb7930165c config validation
publish / publish (push) Waiting to run
2025-06-20 00:57:28 -04:00
Rohan Godha d9d0d6725f fix: phantom input bug on wsl (#200) 2025-06-19 20:08:56 -05:00
Tom Watkins 13212960b8 docs: fix typo in config.mdx (#218) 2025-06-19 21:08:21 -04:00
Dax Raad 66114f4480 fix for azure models not liking tool definitions
publish / publish (push) Waiting to run
2025-06-19 18:28:42 -04:00
Jay V 0638941067 docs: tweak lander 2025-06-19 18:19:35 -04:00
Jay 78341ed135 docs: Update README.md 2025-06-19 18:19:06 -04:00
Dax Raad 2f35addf54 remove unused permission timeout
publish / publish (push) Waiting to run
2025-06-19 18:00:53 -04:00
Dax Raad 692ee660c2 temporarily disable project details in system prompt
publish / publish (push) Waiting to run
2025-06-19 17:37:23 -04:00
Frank 6c28afed0b Infra: fix DO tag 2025-06-19 17:20:13 -04:00
Dax Raad 58a19b9b7a fix gopls download spewing into terminal
publish / publish (push) Waiting to run
2025-06-19 17:08:58 -04:00
adamdottv bed7486889 fix(tui): upgrade notification
publish / publish (push) Waiting to run
2025-06-19 16:03:45 -05:00
Jay V 0e42178044 docs: share 2025-06-19 16:57:42 -04:00
adamdottv 8374977903 fix(tui): better help on home
publish / publish (push) Waiting to run
2025-06-19 15:56:28 -05:00
Jay V df3adc416b docs: tweak 2025-06-19 16:40:15 -04:00
Jay V 16f13a7ef6 docs: copy 2025-06-19 16:39:36 -04:00
Jay V ba24bbe1ea docs: styles 2025-06-19 16:38:37 -04:00
Jay V 943c6657a4 docs: adding to config 2025-06-19 16:36:17 -04:00
Dax Raad 7a073130d7 ignore: do migration 2025-06-19 16:32:32 -04:00
Jay V 16fceab8c6 docs: readme
publish / publish (push) Waiting to run
2025-06-19 16:29:20 -04:00
Jay V f72483cbcc docs: edits 2025-06-19 16:28:11 -04:00
Jay V b1f79ca730 Copy changes 2025-06-19 16:28:03 -04:00
adamdottv a0ffc4e7ae fix(tui): better tool titles 2025-06-19 15:11:53 -05:00
adamdottv a879a388a0 feat(tui): better task tool rendering
publish / publish (push) Waiting to run
2025-06-19 15:02:13 -05:00
adamdottv 5f25476682 fix(tui): sorted tool arg maps 2025-06-19 14:07:33 -05:00
adamdottv bcec113325 fix: typescript error 2025-06-19 13:57:25 -05:00
adamdottv 16cc70eda1 fix(tui): sorted tool arg maps 2025-06-19 13:56:09 -05:00
Dax Raad e103581f95 use production share url
publish / publish (push) Waiting to run
2025-06-19 14:21:00 -04:00
Dax Raad d7ae3c6033 ci: share 2025-06-19 13:26:15 -04:00
Dax Raad 9b49eee4a6 add schema descriptions to config fields
Enhance configuration schema with descriptive text for all fields to improve developer experience and auto-generated documentation.

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-19 13:12:13 -04:00
Dax Raad f5bc0a6de8 fix task agent performance issues
publish / publish (push) Waiting to run
2025-06-19 13:00:57 -04:00
Dax Raad fc079b67b2 allow selecting model and continuing previous session for opencode run 2025-06-19 13:00:57 -04:00
adamdottv 45f06f3068 fix(tui): vscode and mac terminal colors 2025-06-19 11:46:08 -05:00
adamdottv 9af0aa12ba fix(tui): overlay border backgrounds 2025-06-19 11:41:30 -05:00
Jay V b5e6f888ec ignore: share version 2025-06-19 12:20:30 -04:00
adamdottv 225a12c170 chore: cleanup logs 2025-06-19 10:52:45 -05:00
adamdottv 44919b1270 fix(tui): toast placement and overlay rendering 2025-06-19 10:45:10 -05:00
adamdottv 9ccb743875 feat(tui): more toast messages 2025-06-19 10:41:59 -05:00
adamdottv 0d6c436fc8 feat(tui): toast messages 2025-06-19 10:12:29 -05:00
Dax Raad 3d79b566f5 fix webfetch tool when returning html as text
publish / publish (push) Waiting to run
2025-06-19 10:43:54 -04:00
Dax Raad a44cc4a140 support token caching for anthropic via openrouter
publish / publish (push) Waiting to run
2025-06-19 10:32:14 -04:00
adamdottv 8bf54ee56d feat(tui): copy share url to clipboard
publish / publish (push) Waiting to run
2025-06-19 09:06:25 -05:00
Dax Raad b7319a304f remove opencode_ prefixes from tool names. unfortunately this will break
publish / publish (push) Waiting to run
all old sessions and share links. we'll be more backwards compatible in
the future once we're more stable.
2025-06-19 09:59:44 -04:00
adamdottv 8c6e559179 feat(tui): expand input to fit message
publish / publish (push) Waiting to run
2025-06-19 08:45:27 -05:00
Dax Raad b8d070cf5e ensure session.info is synced when shared
publish / publish (push) Waiting to run
2025-06-19 09:41:11 -04:00
adamdottv 553d5d3890 fix: include schema in converted toml config 2025-06-19 06:02:02 -05:00
Guillermo Antony Cava Nuñez 5aaffea356 Fixes tool tip layering (#199) 2025-06-19 00:23:29 -04:00
Dax Raad c596119a4f hooks 2025-06-19 00:20:03 -04:00
Frank 0b12b10fe6 Update SST 2025-06-18 23:38:06 -04:00
Josh 0a853034e5 fix: incorrect command on main screen for exiting application (#201)
publish / publish (push) Waiting to run
2025-06-18 23:19:43 -04:00
Dax Raad a0211c03d5 stop loading models.dev format from global config
publish / publish (push) Waiting to run
2025-06-18 23:08:51 -04:00
Dax Raad 3a9b34569d docs: update README 2025-06-18 23:03:54 -04:00
Dax Raad 71cd048840 BREAKING CONFIG CHANGE
publish / publish (push) Waiting to run
We have changed the config format yet again - but this should be the
final time. You can see the readme for more details but the summary is

- got rid of global providers config
- got rid of global toml
- global config is now in `~/.config/opencode/config.json`
- it will be merged with any project level config
2025-06-18 23:01:19 -04:00
Dax Raad 430bbb205c rework config 2025-06-18 23:01:19 -04:00
Frank f1fb0c5395 Render version 2025-06-18 22:26:51 -04:00
Jay V 7230d51718 ignore: share copy link 2025-06-18 20:18:10 -04:00
Dax Raad df777268e8 support global config 2025-06-18 18:56:52 -04:00
Jay V 7c4db19c75 ignore: share logo 2025-06-18 18:33:51 -04:00
Jay V 691c1ddeca ignore: share 2025-06-18 18:22:19 -04:00
Jay V 7006b1b080 ignore: share 2025-06-18 18:21:44 -04:00
Jay V 597a270b2e ignore: share favicon 2025-06-18 18:10:22 -04:00
Jay V 36a916f8b8 ignore: share 2025-06-18 18:01:34 -04:00
adamdottv 3854f6a63f feat(tui): show provider next to model 2025-06-18 16:09:49 -05:00
adamdottv aa99745a9d chore: tui style tweaks 2025-06-18 15:59:58 -05:00
Jay V a075a4e129 ignore: share 2025-06-18 16:54:33 -04:00
Jay V 652eff9d3f ignore: share 2025-06-18 16:54:33 -04:00
adamdottv 91107f2889 fix(tui): file completion quirks 2025-06-18 15:51:26 -05:00
Frank b62546f9ba Api: only return session messages 2025-06-18 16:20:34 -04:00
Dax Raad 5872e83fbe ignore: types 2025-06-18 16:20:03 -04:00
Jay V 3658e54047 ignore: share 2025-06-18 16:15:22 -04:00
Jay V 6121ef35d5 ignore: share 2025-06-18 16:15:11 -04:00
Dax Raad 3013a08eb4 ignore: share page stuff 2025-06-18 16:13:33 -04:00
adamdottv 14334dbdb2 fix(tui): modal visuals 2025-06-18 15:12:24 -05:00
Dax Raad 735f79badc share types 2025-06-18 15:34:13 -04:00
Jay V 0eb7cf5ce5 ignore: share 2025-06-18 15:32:40 -04:00
adamdottv 60b5b046ab chore: cleanup logs 2025-06-18 14:18:46 -05:00
Dax Raad 5f616b2367 docs: improve keybinds configuration format in README
publish / publish (push) Waiting to run
Update keybinds configuration example to use proper TOML table syntax instead of dot notation for better readability and standard TOML formatting.

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-18 15:10:46 -04:00
adamdottv 2fe9c6180c fix(tui): help dialog sorting 2025-06-18 14:06:20 -05:00
adamdottv 4e0821f7d9 feat(tui): configurable keybinds and mouse scroll 2025-06-18 13:56:51 -05:00
Dax Raad f97eacfbeb ctrl+c should gracefully clean up pending sessions
publish / publish (push) Waiting to run
2025-06-18 14:11:49 -04:00
Jay V b8d929d8f4 ignore: share error styles 2025-06-18 14:10:14 -04:00
Dax Raad dd599c496c fix panic when invalid config
publish / publish (push) Waiting to run
2025-06-18 14:03:16 -04:00
Dax Raad 3121d84ede track version on session info
publish / publish (push) Waiting to run
2025-06-18 13:40:36 -04:00
Jay V eee92921f4 ignore: share page styles 2025-06-18 12:53:48 -04:00
Dax Raad f5400000a0 implemented diff trimming 2025-06-18 11:20:40 -04:00
Dax Raad 7adba0e933 allow setting options in global provider store
publish / publish (push) Waiting to run
2025-06-18 11:06:16 -04:00
Dax Raad 4c0b88dca8 fix issue with provider cache
publish / publish (push) Waiting to run
2025-06-18 10:56:23 -04:00
Dax Raad 99ad27bbd8 fix deploys 2025-06-18 10:47:07 -04:00
Dax Raad aaec44f2d5 remove call to npm causing noticible delay when starting chat
publish / publish (push) Waiting to run
2025-06-18 10:35:41 -04:00
Dax Raad 7e6fb4e0c8 fix issue when running opencode in empty directory
publish / publish (push) Waiting to run
2025-06-18 10:29:09 -04:00
Dax Raad bc4bdfc1b1 ignore: sync
publish / publish (push) Waiting to run
2025-06-18 08:36:25 -04:00
SBSTN 40c1d2af24 Add Everforest Theme (#170) 2025-06-18 05:55:38 -05:00
Dax Raad cf8dd4a1f8 integrate cache read/write data
publish / publish (push) Waiting to run
2025-06-17 20:51:39 -04:00
Jay V 24dcfee294 ignore: share page polish 2025-06-17 20:26:12 -04:00
Dax Raad c0f019ff08 improve anthropic oauth token caching and authentication handling
publish / publish (push) Waiting to run
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-17 13:23:15 -04:00
Dax Raad 1bd6809d36 ignore: fix 2025-06-17 13:04:26 -04:00
Dax Raad dbcd784a82 state 2025-06-17 12:29:28 -04:00
Dax Raad 42f1d9b0d8 support disabling providers from automatically being added
publish / publish (push) Waiting to run
2025-06-17 12:23:04 -04:00
Dax Raad 04ea736dc2 docs: readme 2025-06-17 12:17:45 -04:00
Dax Raad f4bc2c2c05 docs: update README
publish / publish (push) Waiting to run
2025-06-17 12:14:38 -04:00
Dax Raad a71568d84b docs: readme 2025-06-17 12:12:33 -04:00
Dax Raad ba5cc709d1 support global config for providers 2025-06-17 12:10:44 -04:00
Dax Raad 53c3aa88c0 fix issue with tool schemas and google
publish / publish (push) Waiting to run
2025-06-17 11:27:07 -04:00
Dax Raad 32a0bf3d15 fix agent getting caught in summary loop
publish / publish (push) Waiting to run
2025-06-17 10:50:03 -04:00
Dax Raad 2c19d39737 ci: fix
publish / publish (push) Waiting to run
2025-06-17 10:38:01 -04:00
Dax Raad 7ce22fa132 Fix TypeScript compilation errors and consolidate version handling
publish / publish (push) Waiting to run
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-17 10:27:49 -04:00
adamdottv bbd7c065f4 feat: better collapsed tool call visuals
publish / publish (push) Waiting to run
2025-06-17 08:35:18 -05:00
adamdottv 60c10c7b68 chore: consolidate chat page into tui.go 2025-06-17 07:09:04 -05:00
Dax Raad 60fe0aa090 Add autoshare configuration and improve run command UI
Enables automatic session sharing via global config or flag, enhances UI with logo display and provider/model info positioning.

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-17 01:45:32 -04:00
Dax Raad 8084990a31 do not autoupgrade snapshot builds
publish / publish (push) Waiting to run
2025-06-17 01:18:32 -04:00
Dax Raad f544a01971 autoupgrade
publish / publish (push) Waiting to run
2025-06-17 01:05:05 -04:00
Dax e188879d8f Improve upgrade command with installation method detection (#158) 2025-06-17 00:07:17 -04:00
Jay ce1e42b7a1 docs: Update README.md 2025-06-16 21:01:38 -04:00
Jay V bade721218 docs: readme 2025-06-16 20:10:19 -04:00
adamdottv ffe9b6df7e feat: share and init commands
publish / publish (push) Waiting to run
2025-06-16 15:58:52 -05:00
Dax Raad 45d2fe2027 ci: fixed ci issue 2025-06-16 16:58:25 -04:00
Dax Raad b40da9923d docs: readme 2025-06-16 16:53:43 -04:00
Dax Raad 0349548abd ignore: logs 2025-06-16 16:02:45 -04:00
Dax Raad a21aa1a52a docs: update readme 2025-06-16 15:42:35 -04:00
Miles Till 7d023c0ce4 fix: incorrect lipgloss version (#131) 2025-06-16 14:35:46 -05:00
Dax Raad 2cf3ab55ce remove windows builds
publish / publish (push) Waiting to run
2025-06-16 15:11:14 -04:00
Dax Raad 115a71fd20 opencode auth login: validation on provider id and better error messages
publish / publish (push) Waiting to run
2025-06-16 15:09:49 -04:00
Dax Raad 15990d80a8 docs: readme 2025-06-16 15:04:32 -04:00
Dax Raad 0ecff22f98 BREAKING CHANGE: the config structure has changed, custom providers have an npm field now to specify which npm package to load. see examples in README.md 2025-06-16 15:02:25 -04:00
Dax Raad c0a3e5b0f7 limit to 4 system prompts cached
publish / publish (push) Waiting to run
2025-06-16 14:51:59 -04:00
adamdottv b0b5d2a5cc fix: include cached tokens in tui 2025-06-16 12:59:38 -05:00
adamdottv aa90b03da6 feat: believe it or not, even faster tui init
publish / publish (push) Waiting to run
2025-06-16 12:34:34 -05:00
adamdottv c482fe1ac8 feat: even faster tui init 2025-06-16 12:24:18 -05:00
adamdottv 6120b64792 feat: faster tui init
publish / publish (push) Waiting to run
2025-06-16 11:54:55 -05:00
Dax Raad b5723b3ace properly track cache token counts
publish / publish (push) Waiting to run
2025-06-16 12:43:22 -04:00
Dax Raad f3e32fe112 fix cache-control
publish / publish (push) Waiting to run
2025-06-16 12:07:01 -04:00
adamdottv d120eafd9e fix: default provider and model 2025-06-16 10:51:01 -05:00
adamdottv 34823c4bc5 chore: remove status service 2025-06-16 10:45:19 -05:00
Dax Raad 0ca81eb0d3 enable prompt caching for anthropic
publish / publish (push) Waiting to run
2025-06-16 11:41:54 -04:00
adamdottv 03a017e8c7 fix: spinner background color 2025-06-16 10:03:44 -05:00
Dax Raad 49439d1cd8 docs: readme 2025-06-16 10:44:48 -04:00
Thomas Meire e4f6c1e3ce ignore: remove log files and add them to gitignore (#138) 2025-06-16 09:30:07 -04:00
Jacob 3534e727bf fix: typo and literal wording in packages/opencode/AGENTS.md (#134) 2025-06-16 08:18:29 -05:00
Dax Raad c2379097e0 docs: update README 2025-06-15 21:43:20 -04:00
Dax Raad fd64aa4c25 docs: readme 2025-06-15 21:39:02 -04:00
Dax Raad a205b1e7f5 enhance ripgrep files function with query filtering and limit support
publish / publish (push) Waiting to run
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-15 21:26:32 -04:00
Dax Raad 89c49bf253 first pass making system prompt less fast
publish / publish (push) Waiting to run
2025-06-15 20:25:04 -04:00
George Potoshin a8c4a6b3da Fix: Improve Help UI Readability (Issue #99) (#117) 2025-06-15 18:38:44 -05:00
adamdottv 742eed5ce4 fix: background color rendering issues
publish / publish (push) Waiting to run
2025-06-15 15:07:05 -05:00
Pierre B. 4c033aa9b2 fix: spelling, grammar and typos (#121) 2025-06-15 14:23:18 -05:00
Dax Raad 1fe40495d2 Improve LSP server initialization with timeout handling and skip failed servers
publish / publish (push) Waiting to run
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-15 13:52:57 -04:00
Dax Raad d142ec3b1d ignore: add timer log helper 2025-06-15 13:33:24 -04:00
Dax Raad 9d8f080da1 fix postinstall script for node
publish / publish (push) Waiting to run
2025-06-15 13:11:11 -04:00
Pierre B. 3eece9d138 chore: ignore intellij, vscode (#122) 2025-06-15 10:40:34 -05:00
adamdottv 1a4f17a827 fix: background color rendering issues
publish / publish (push) Waiting to run
2025-06-15 05:57:15 -05:00
szymon bf231d5469 remove .DS_Store (#112) 2025-06-15 05:34:46 -05:00
Dax Raad 44cc41abb0 beginning of upgrade command 2025-06-14 22:05:41 -04:00
Dax Raad e140c098ed revert ctrl+d - conflicts with page down
publish / publish (push) Waiting to run
2025-06-14 21:29:02 -04:00
Dax Raad 9871d97af1 fix aborting issue
publish / publish (push) Waiting to run
2025-06-14 21:23:57 -04:00
Dax Raad 502098549b fix issue with follow up tool calls and cancelation
publish / publish (push) Waiting to run
2025-06-14 21:03:44 -04:00
Dax Raad 24f1399bcf fix issue continuing session after aborted 2025-06-14 20:24:50 -04:00
nitishxyz 1e0a50bf89 Add Ayu dark theme (#109)
publish / publish (push) Waiting to run
2025-06-14 20:08:31 -04:00
Berr 233e07c66f fix: improve browser opening error handling in AuthLoginCommand (#111) 2025-06-14 20:07:41 -04:00
Dax Raad 32f82e91d2 sync 2025-06-14 20:04:41 -04:00
Dax Raad 70405b2705 ci: ignore commits 2025-06-14 18:59:05 -04:00
Dax Raad d8085d6609 docs: typo 2025-06-14 18:58:27 -04:00
Rohan Godha c4f03b1039 tui: fix help dialog background (#110)
publish / publish (push) Waiting to run
2025-06-14 18:57:15 -04:00
Conor O'Brien 1ef5a6b321 nit: update commands displayed on home to match commands available (#108) 2025-06-14 18:56:44 -04:00
Gal Schlezinger 86b27f602d make ctrl+d quit too, just like shells (#105) 2025-06-14 18:56:34 -04:00
Dax Raad a760394536 docs: use ollama example 2025-06-14 18:55:39 -04:00
Dax Raad ec9555e957 downgrade to ai sdk v4.x
publish / publish (push) Waiting to run
2025-06-14 18:44:08 -04:00
Gal Schlezinger def232321f add focus tracking for tui so cursor will hide when not in focus (#103) 2025-06-14 14:53:43 -05:00
Dax Raad 158e0d6e1c docs: readme 2025-06-14 14:52:02 -04:00
Dax Raad 1ad42d91ec docs: provider config 2025-06-14 14:45:59 -04:00
Dax Raad 1c6cfee713 docs: faq 2025-06-14 14:39:13 -04:00
Mantena Rama Raju ed53b3e331 typo (#94) 2025-06-14 14:36:29 -04:00
Dax Raad 9ba49a23cd Enhance provider system with dynamic package resolution and improved logging
publish / publish (push) Waiting to run
- Add npm registry lookup for AI SDK packages with fallback support
- Enhance error logging with cause information
- Add timing deltas to log output for performance monitoring

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-14 14:35:33 -04:00
Albert Ilagan 6438647eef tui: remove quit dialog (#97) 2025-06-14 12:47:34 -05:00
Dax Raad b3068ac292 doc: remove dev script 2025-06-14 13:05:23 -04:00
Dax Raad 76ba37611b docs: contributing
publish / publish (push) Waiting to run
2025-06-14 12:45:26 -04:00
Dax Raad 5862327e26 sync 2025-06-14 12:32:41 -04:00
Dax Raad 81468d6ee4 docs: mcp 2025-06-14 12:25:26 -04:00
Dax Raad a1d5ac0c45 docs: config schema 2025-06-14 12:22:07 -04:00
Dax Raad 88bf2e3e77 ci: update publish script 2025-06-14 12:13:59 -04:00
Dax Raad 7f4d13b76d docs: readme 2025-06-14 12:13:46 -04:00
Dax Raad 7ae04a8797 naming fixes
publish / publish (push) Waiting to run
2025-06-14 01:54:28 -04:00
Dax Raad 986c354a39 Add flag system and auto-share functionality
publish / publish (push) Waiting to run
- Add Flag module for environment variable configuration
- Implement OPENCODE_AUTO_SHARE flag to automatically share new sessions
- Update session creation to conditionally auto-share based on flag

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

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-14 01:51:04 -04:00
Dax Raad a568f4058d Improve logging and simplify fzf implementation
- Refactor fzf search to use Bun's $ syntax for cleaner command execution
- Add request/response duration logging to server middleware
- Set default service name for logging to improve log clarity

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

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-14 01:51:04 -04:00
Alireza Bahrami 6f1d970962 fix(install): check if the path export command already exists (#28) 2025-06-13 23:28:33 -04:00
Dax Raad 7da025f4fb Clean up workflows and enhance file discovery tools to include dot files
publish / publish (push) Waiting to run
🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-13 23:24:46 -04:00
Dax Raad a86c9511c7 sync 2025-06-13 17:42:56 -04:00
Dax Raad 6c802425ee screenshot 2025-06-13 17:42:14 -04:00
Dax Raad 6c9b8b010f disable autoshare
publish / publish (push) Waiting to run
2025-06-13 17:30:17 -04:00
Dax Raad 048958d7f7 sync 2025-06-13 17:24:45 -04:00
Dax Raad f42e4b7443 sync
publish / publish (push) Waiting to run
2025-06-13 17:23:22 -04:00
adamdottv e57cf4a4ce wip: refactoring tui 2025-06-13 15:56:33 -05:00
Dax Raad e02b728a81 sync
publish / publish (push) Waiting to run
2025-06-13 16:47:15 -04:00
Dax Raad 0a7c4a5389 sync 2025-06-13 16:46:48 -04:00
Dax Raad a177d3fa37 sync 2025-06-13 16:45:58 -04:00
Dax Raad f386dd5789 sync 2025-06-13 16:43:53 -04:00
Dax Raad 574b4eda99 commit and push 2025-06-13 16:42:31 -04:00
Dax Raad 2af22d2ec8 sync 2025-06-13 16:37:58 -04:00
Dax Raad 93e3be05a3 sync
publish / publish (push) Waiting to run
2025-06-13 16:29:57 -04:00
Dax Raad 3f1700cc34 reset readme 2025-06-13 16:26:34 -04:00
Dax Raad b6c79abca2 ci
publish / publish (push) Waiting to run
2025-06-13 16:23:38 -04:00
Dax Raad b86c1c9151 homebrew
publish / publish (push) Waiting to run
2025-06-13 16:17:27 -04:00
adamdottv 50d6057777 wip: refactoring tui
publish / publish (push) Waiting to run
2025-06-13 11:27:05 -05:00
adamdottv 8cd554f84c wip: refactoring tui 2025-06-13 11:18:46 -05:00
adamdottv 9ad13b3197 wip: refactoring tui 2025-06-13 10:47:51 -05:00
adamdottv 453298c1c0 wip: refactoring tui 2025-06-13 09:57:54 -05:00
adamdottv 2d8ce02971 wip: refactoring tui 2025-06-13 09:44:09 -05:00
adamdottv 09492cfeb7 wip: refactoring tui 2025-06-13 09:19:51 -05:00
adamdottv eda1445856 wip: refactoring tui 2025-06-13 08:30:57 -05:00
adamdottv 161634d663 wip: refactoring tui 2025-06-13 08:30:56 -05:00
Dax Raad ba1d90b1b9 do not strip aur package
publish / publish (push) Waiting to run
2025-06-13 08:27:17 -04:00
adamdottv fe1d09b88d wip: refactoring tui
publish / publish (push) Waiting to run
2025-06-13 07:01:26 -05:00
adamdottv fae45d757f wip: refactoring tui 2025-06-13 06:56:12 -05:00
adamdottv 93db8564a5 wip: refactoring tui 2025-06-13 06:49:59 -05:00
adamdottv da824c82b6 wip: refactoring tui 2025-06-13 06:23:12 -05:00
Dax Raad 30176d32ba load API keys
publish / publish (push) Waiting to run
2025-06-13 00:53:46 -04:00
Dax Raad aa330249c4 Enhance auth command with dynamic provider selection
- Add support for dynamically loading providers from ModelsDev
- Prioritize anthropic as recommended provider
- Add "other" provider option for manual entry
- Include special handling for amazon-bedrock with AWS config guidance
- Expand provider selection UI to show up to 8 providers

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

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-13 00:33:54 -04:00
Dax Raad d0f3211c7f sync 2025-06-13 00:06:15 -04:00
Dax Raad 899e5f38b2 Refactor AI SDK provider loading to use BunProc.install
Simplifies provider installation by using BunProc.install() instead of manual path construction and file system checks.

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

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-12 23:50:26 -04:00
Dax Raad fba34a04fa bedrock loader
publish / publish (push) Waiting to run
2025-06-12 23:39:52 -04:00
Dax Raad 3c0a28dbed Update provider configuration and server handling
🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-12 23:10:03 -04:00
217 changed files with 19865 additions and 9188 deletions
-37
View File
@@ -1,37 +0,0 @@
name: build
on:
workflow_dispatch:
push:
branches:
- dev
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v5
with:
go-version: ">=1.23.2"
cache: true
cache-dependency-path: go.sum
- run: go mod download
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: build --snapshot --clean
+4 -3
View File
@@ -3,7 +3,8 @@ name: deploy
on:
push:
branches:
- dontlook
- dev
- production
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -16,10 +17,10 @@ jobs:
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
bun-version: 1.2.17
- run: bun install
- run: bun sst deploy --stage=dev
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+2 -2
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- dontlook
- dev
tags:
- "*"
@@ -32,7 +32,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.16
bun-version: 1.2.17
- name: Install makepkg
run: |
+2
View File
@@ -3,3 +3,5 @@ node_modules
.opencode
.sst
.env
.idea
.vscode
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 OpenCode
Copyright (c) 2025 opencode
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+60 -631
View File
@@ -1,658 +1,87 @@
◧ opencode
<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">
</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://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](screenshot.png)
[![opencode Terminal UI](packages/web/src/assets/themes/opencode.png)](https://opencode.ai)
> **⚠️ Notice:** We are in progress of a complete overhaul in the `dontlook` branch - should be released mid June. The README below is for the current version
---
A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
## Overview
OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
## Features
- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, Groq, Azure OpenAI, and OpenRouter
- **Session Management**: Save and manage multiple conversation sessions
- **Tool Integration**: AI can execute commands, search files, and modify code
- **Vim-like Editor**: Integrated editor with text input capabilities
- **Persistent Storage**: SQLite database for storing conversations and sessions
- **LSP Integration**: Language Server Protocol support for code intelligence
- **File Change Tracking**: Track and visualize file changes during sessions
- **External Editor Support**: Open your preferred editor for composing messages
- **Named Arguments for Custom Commands**: Create powerful custom commands with multiple named placeholders
## Installation
### Using the Install Script
### Installation
```bash
# Install the latest version
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Install a specific version
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
# Package managers
npm i -g opencode-ai@latest # or bun/pnpm/yarn
brew install sst/tap/opencode # macOS
paru -S opencode-bin # Arch Linux
```
### Using Homebrew (macOS and Linux)
> **Note:** Remove versions older than 0.1.x before installing
### Documentation
For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
### Contributing
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.
To run opencode locally you need.
- Bun
- Golang 1.24.x
And run.
```bash
brew install sst/tap/opencode
$ bun install
$ bun run packages/opencode/src/index.ts
```
### Using AUR (Arch Linux)
#### 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
# Using yay
yay -S opencode-bin
# Using paru
paru -S opencode-bin
$ cd packages/tui
$ go generate ./pkg/client/
```
### Using Go
This updates the generated Go client code that the TUI uses to communicate with the backend server.
```bash
go install github.com/sst/opencode@latest
```
### FAQ
## Configuration
#### How is this different than Claude Code?
OpenCode looks for configuration in the following locations:
It's very similar to Claude Code in terms of capability. Here are the key differences:
- `$HOME/.opencode.json`
- `$XDG_CONFIG_HOME/opencode/.opencode.json`
- `./.opencode.json` (local directory)
- 100% open source
- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
- 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.
### Environment Variables
#### What about Windows support?
You can configure OpenCode using environment variables:
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.
| Environment Variable | Purpose |
| -------------------------- | ------------------------------------------------------ |
| `ANTHROPIC_API_KEY` | For Claude models |
| `OPENAI_API_KEY` | For OpenAI models |
| `GEMINI_API_KEY` | For Google Gemini models |
| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
| `GROQ_API_KEY` | For Groq models |
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
| `AWS_REGION` | For AWS Bedrock (Claude) |
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
#### What's the other repo?
### Configuration File Structure
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).
```json
{
"data": {
"directory": ".opencode"
},
"providers": {
"openai": {
"apiKey": "your-api-key",
"disabled": false
},
"anthropic": {
"apiKey": "your-api-key",
"disabled": false
},
"groq": {
"apiKey": "your-api-key",
"disabled": false
},
"openrouter": {
"apiKey": "your-api-key",
"disabled": false
}
},
"agents": {
"primary": {
"model": "claude-3.7-sonnet",
"maxTokens": 5000
},
"task": {
"model": "claude-3.7-sonnet",
"maxTokens": 5000
},
"title": {
"model": "claude-3.7-sonnet",
"maxTokens": 80
}
},
"mcpServers": {
"example": {
"type": "stdio",
"command": "path/to/mcp-server",
"env": [],
"args": []
}
},
"lsp": {
"go": {
"disabled": false,
"command": "gopls"
}
},
"shell": {
"path": "/bin/zsh",
"args": ["-l"]
},
"debug": false,
"debugLSP": false
}
```
---
## Supported AI Models
OpenCode supports a variety of AI models from different providers:
### OpenAI
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
- GPT-4.5 Preview
- GPT-4o family (gpt-4o, gpt-4o-mini)
- O1 family (o1, o1-pro, o1-mini)
- O3 family (o3, o3-mini)
- O4 Mini
### Anthropic
- Claude 3.5 Sonnet
- Claude 3.5 Haiku
- Claude 3.7 Sonnet
- Claude 3 Haiku
- Claude 3 Opus
### Google
- Gemini 2.5
- Gemini 2.5 Flash
- Gemini 2.0 Flash
- Gemini 2.0 Flash Lite
### AWS Bedrock
- Claude 3.7 Sonnet
### Groq
- Llama 4 Maverick (17b-128e-instruct)
- Llama 4 Scout (17b-16e-instruct)
- QWEN QWQ-32b
- Deepseek R1 distill Llama 70b
- Llama 3.3 70b Versatile
### Azure OpenAI
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
- GPT-4.5 Preview
- GPT-4o family (gpt-4o, gpt-4o-mini)
- O1 family (o1, o1-mini)
- O3 family (o3, o3-mini)
- O4 Mini
### Google Cloud VertexAI
- Gemini 2.5
- Gemini 2.5 Flash
## Using Bedrock Models
To use bedrock models with OpenCode you need three things.
1. Valid AWS credentials (the env vars: `AWS_SECRET_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION`)
2. Access to the corresponding model in AWS Bedrock in your region.
a. You can request access in the AWS console on the Bedrock -> "Model access" page.
3. A correct configuration file. You don't need the `providers` key. Instead you have to prefix your models per agent with `bedrock.` and then a valid model. For now only Claude 3.7 is supported.
```json
{
"agents": {
"primary": {
"model": "bedrock.claude-3.7-sonnet",
"maxTokens": 5000,
"reasoningEffort": ""
},
"task": {
"model": "bedrock.claude-3.7-sonnet",
"maxTokens": 5000,
"reasoningEffort": ""
},
"title": {
"model": "bedrock.claude-3.7-sonnet",
"maxTokens": 80,
"reasoningEffort": ""
}
}
}
```
## Interactive Mode Usage
```bash
# Start OpenCode
opencode
# Start with debug logging
opencode -d
# Start with a specific working directory
opencode -c /path/to/project
```
## Non-interactive Prompt Mode
You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument or by piping text into the command. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
```bash
# Run a single prompt and print the AI's response to the terminal
opencode -p "Explain the use of context in Go"
# Pipe input to OpenCode (equivalent to using -p flag)
echo "Explain the use of context in Go" | opencode
# Get response in JSON format
opencode -p "Explain the use of context in Go" -f json
# Or with piped input
echo "Explain the use of context in Go" | opencode -f json
# Run without showing the spinner
opencode -p "Explain the use of context in Go" -q
# Or with piped input
echo "Explain the use of context in Go" | opencode -q
# Enable verbose logging to stderr
opencode -p "Explain the use of context in Go" --verbose
# Or with piped input
echo "Explain the use of context in Go" | opencode --verbose
# Restrict the agent to only use specific tools
opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob
# Or with piped input
echo "Explain the use of context in Go" | opencode --allowedTools=view,ls,glob
# Prevent the agent from using specific tools
opencode -p "Explain the use of context in Go" --excludedTools=bash,edit
# Or with piped input
echo "Explain the use of context in Go" | opencode --excludedTools=bash,edit
```
In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.
### Tool Restrictions
You can control which tools the AI assistant has access to in non-interactive mode:
- `--allowedTools`: Comma-separated list of tools that the agent is allowed to use. Only these tools will be available.
- `--excludedTools`: Comma-separated list of tools that the agent is not allowed to use. All other tools will be available.
These flags are mutually exclusive - you can use either `--allowedTools` or `--excludedTools`, but not both at the same time.
### Output Formats
OpenCode supports the following output formats in non-interactive mode:
| Format | Description |
| ------ | ------------------------------- |
| `text` | Plain text output (default) |
| `json` | Output wrapped in a JSON object |
The output format is implemented as a strongly-typed `OutputFormat` in the codebase, ensuring type safety and validation when processing outputs.
## Command-line Flags
| Flag | Short | Description |
| ----------------- | ----- | --------------------------------------------------- |
| `--help` | `-h` | Display help information |
| `--debug` | `-d` | Enable debug mode |
| `--cwd` | `-c` | Set current working directory |
| `--prompt` | `-p` | Run a single prompt in non-interactive mode |
| `--output-format` | `-f` | Output format for non-interactive mode (text, json) |
| `--quiet` | `-q` | Hide spinner in non-interactive mode |
| `--verbose` | | Display logs to stderr in non-interactive mode |
| `--allowedTools` | | Restrict the agent to only use specified tools |
| `--excludedTools` | | Prevent the agent from using specified tools |
## Keyboard Shortcuts
### Global Shortcuts
| Shortcut | Action |
| -------- | ------------------------------------------------------- |
| `Ctrl+C` | Quit application |
| `Ctrl+?` | Toggle help dialog |
| `?` | Toggle help dialog (when not in editing mode) |
| `Ctrl+L` | View logs |
| `Ctrl+A` | Switch session |
| `Ctrl+K` | Command dialog |
| `Ctrl+O` | Toggle model selection dialog |
| `Esc` | Close current overlay/dialog or return to previous mode |
### Chat Page Shortcuts
| Shortcut | Action |
| -------- | --------------------------------------- |
| `Ctrl+N` | Create new session |
| `Ctrl+X` | Cancel current operation/generation |
| `i` | Focus editor (when not in writing mode) |
| `Esc` | Exit writing mode and focus messages |
### Editor Shortcuts
| Shortcut | Action |
| ------------------- | ----------------------------------------- |
| `Ctrl+S` | Send message (when editor is focused) |
| `Enter` or `Ctrl+S` | Send message (when editor is not focused) |
| `Ctrl+E` | Open external editor |
| `Esc` | Blur editor and focus messages |
### Session Dialog Shortcuts
| Shortcut | Action |
| ---------- | ---------------- |
| `↑` or `k` | Previous session |
| `↓` or `j` | Next session |
| `Enter` | Select session |
| `Esc` | Close dialog |
### Model Dialog Shortcuts
| Shortcut | Action |
| ---------- | ----------------- |
| `↑` or `k` | Move up |
| `↓` or `j` | Move down |
| `←` or `h` | Previous provider |
| `→` or `l` | Next provider |
| `Esc` | Close dialog |
### Permission Dialog Shortcuts
| Shortcut | Action |
| ----------------------- | ---------------------------- |
| `←` or `left` | Switch options left |
| `→` or `right` or `tab` | Switch options right |
| `Enter` or `space` | Confirm selection |
| `a` | Allow permission |
| `A` | Allow permission for session |
| `d` | Deny permission |
### Logs Page Shortcuts
| Shortcut | Action |
| ------------------ | ------------------- |
| `Backspace` or `q` | Return to chat page |
## AI Assistant Tools
OpenCode's AI assistant has access to various tools to help with coding tasks:
### File and Code Tools
| Tool | Description | Parameters |
| ------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
| `glob` | Find files by pattern | `pattern` (required), `path` (optional) |
| `grep` | Search file contents | `pattern` (required), `path` (optional), `include` (optional), `literal_text` (optional) |
| `ls` | List directory contents | `path` (optional), `ignore` (optional array of patterns) |
| `view` | View file contents | `file_path` (required), `offset` (optional), `limit` (optional) |
| `write` | Write to files | `file_path` (required), `content` (required) |
| `edit` | Edit files | Various parameters for file editing |
| `patch` | Apply patches to files | `file_path` (required), `diff` (required) |
| `diagnostics` | Get diagnostics information | `file_path` (optional) |
### Other Tools
| Tool | Description | Parameters |
| ------- | ------------------------------- | ----------------------------------------------------------- |
| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
### Shell Configuration
OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:
1. The shell specified in the config file (if provided)
2. The shell from the `$SHELL` environment variable (if available)
3. Falls back to `/bin/bash` if neither of the above is available
To configure a custom shell, add a `shell` section to your `.opencode.json` configuration file:
```json
{
"shell": {
"path": "/bin/zsh",
"args": ["-l"]
}
}
```
You can specify any shell executable and custom arguments:
```json
{
"shell": {
"path": "/usr/bin/fish",
"args": []
}
}
```
## Architecture
OpenCode is built with a modular architecture:
- **cmd**: Command-line interface using Cobra
- **internal/app**: Core application services
- **internal/config**: Configuration management
- **internal/db**: Database operations and migrations
- **internal/llm**: LLM providers and tools integration
- **internal/tui**: Terminal UI components and layouts
- **internal/logging**: Logging infrastructure
- **internal/message**: Message handling
- **internal/session**: Session management
- **internal/lsp**: Language Server Protocol integration
## Custom Commands
OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
### Creating Custom Commands
Custom commands are predefined prompts stored as Markdown files in one of three locations:
1. **User Commands** (prefixed with `user:`):
```
$XDG_CONFIG_HOME/opencode/commands/
```
(typically `~/.config/opencode/commands/` on Linux/macOS)
or
```
$HOME/.opencode/commands/
```
2. **Project Commands** (prefixed with `project:`):
```
<PROJECT DIR>/.opencode/commands/
```
Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
```markdown
RUN git ls-files
READ README.md
```
This creates a command called `user:prime-context`.
### Command Arguments
OpenCode supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter).
For example:
```markdown
# Fetch Context for Issue $ISSUE_NUMBER
RUN gh issue view $ISSUE_NUMBER --json title,body,comments
RUN git grep --author="$AUTHOR_NAME" -n .
RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
```
When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
- Clear identification of what each argument represents
- Ability to use the same argument multiple times
- Better organization for commands with multiple inputs
### Organizing Commands
You can organize commands in subdirectories:
```
~/.config/opencode/commands/git/commit.md
```
This creates a command with ID `user:git:commit`.
### Using Custom Commands
1. Press `Ctrl+K` to open the command dialog
2. Select your custom command (prefixed with either `user:` or `project:`)
3. Press Enter to execute the command
The content of the command file will be sent as a message to the AI assistant.
## MCP (Model Context Protocol)
OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.
### MCP Features
- **External Tool Integration**: Connect to external tools and services via a standardized protocol
- **Tool Discovery**: Automatically discover available tools from MCP servers
- **Multiple Connection Types**:
- **Stdio**: Communicate with tools via standard input/output
- **SSE**: Communicate with tools via Server-Sent Events
- **Security**: Permission system for controlling access to MCP tools
### Configuring MCP Servers
MCP servers are defined in the configuration file under the `mcpServers` section:
```json
{
"mcpServers": {
"example": {
"type": "stdio",
"command": "path/to/mcp-server",
"env": [],
"args": []
},
"web-example": {
"type": "sse",
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer token"
}
}
}
}
```
### MCP Tool Usage
Once configured, MCP tools are automatically available to the AI assistant alongside built-in tools. They follow the same permission model as other tools, requiring user approval before execution.
## LSP (Language Server Protocol)
OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
### LSP Features
- **Multi-language Support**: Connect to language servers for different programming languages
- **Diagnostics**: Receive error checking and linting information
- **File Watching**: Automatically notify language servers of file changes
### Configuring LSP
Language servers are configured in the configuration file under the `lsp` section:
```json
{
"lsp": {
"go": {
"disabled": false,
"command": "gopls"
},
"typescript": {
"disabled": false,
"command": "typescript-language-server",
"args": ["--stdio"]
}
}
}
```
### LSP Integration with AI
The AI assistant can access LSP features through the `diagnostics` tool, allowing it to:
- Check for errors in your code
- Suggest fixes based on diagnostics
While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
## Development
### Prerequisites
- Go 1.24.0 or higher
### Building from Source
```bash
# Clone the repository
git clone https://github.com/sst/opencode.git
cd opencode
# Build
go build -o opencode
# Run
./opencode
```
## Acknowledgments
OpenCode gratefully acknowledges the contributions and support from these key individuals:
- [@isaacphi](https://github.com/isaacphi) - For the [mcp-language-server](https://github.com/isaacphi/mcp-language-server) project which provided the foundation for our LSP client implementation
- [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture
Special thanks to the broader open source community whose tools and libraries have made this project possible.
## License
OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
## Contributing
Contributions are welcome! Here's how you can contribute:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
Please make sure to update tests as appropriate and follow the existing code style.
**Join our community** [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
+103 -19
View File
@@ -5,7 +5,7 @@
"name": "opencode",
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.4",
"sst": "3.17.6",
},
},
"packages/function": {
@@ -20,6 +20,9 @@
"packages/opencode": {
"name": "opencode",
"version": "0.0.0",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@clack/prompts": "0.11.0",
"@flystorage/file-storage": "1.1.0",
@@ -43,13 +46,17 @@
"yargs": "18.0.0",
"zod": "catalog:",
"zod-openapi": "4.2.4",
"zod-validation-error": "3.5.2",
},
"devDependencies": {
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/anthropic": "1.2.12",
"@tsconfig/bun": "1.0.7",
"@types/bun": "latest",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"typescript": "catalog:",
"zod-to-json-schema": "3.24.5",
},
},
"packages/web": {
@@ -67,16 +74,18 @@
"astro": "5.7.13",
"diff": "8.0.2",
"js-base64": "3.7.7",
"lang-map": "0.4.0",
"luxon": "3.6.1",
"marked": "15.0.12",
"rehype-autolink-headings": "7.1.0",
"sharp": "0.32.5",
"shiki": "3.4.2",
"solid-js": "1.9.7",
"toolbeam-docs-theme": "0.2.4",
"toolbeam-docs-theme": "0.3.0",
},
"devDependencies": {
"@types/node": "catalog:",
"opencode": "workspace:*",
"typescript": "catalog:",
},
},
@@ -85,21 +94,30 @@
"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": "5.0.0-alpha.7",
"ai": "4.3.16",
"typescript": "5.8.2",
"zod": "3.24.2",
},
"packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-alpha.7", "@ai-sdk/provider-utils": "3.0.0-alpha.7" }, "peerDependencies": { "zod": "^3.24.0" } }, "sha512-gz1V165eiJnQIexfLyKm11vimrmQ3zdcJhPpjeLFmDU9wrvZwLuklfZ0WgfYSb+EjiP1cKypwt6JSGvWkfKIAQ=="],
"@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/provider": ["@ai-sdk/provider@2.0.0-alpha.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-lhdrARU3SSmt5p/GNNK7VhazvZpKSCIOjpHUfX7f5jIhVGi/vvlxP1rD6Go57nn1MtuGKNqL04AebSRFDQsQbw=="],
"@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-utils": ["@ai-sdk/provider-utils@3.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-alpha.7", "@standard-schema/spec": "^1.0.0", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-AYkT3jskmo7Lwzijo/yHKD1jC+UZizsROO8ULTg9aJZUwR4ABZzAxh4NxDIEy4TWRfBGufp+/9ICHAn6pkU71w=="],
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@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/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=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
@@ -127,6 +145,12 @@
"@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@0.6.1", "", {}, "sha512-4bMLrs2KW+8/vHEE5Ffv2HbxCbbgXO+2N6MpoCsMXUlUoi7pgEEx8kbkzMXJ2dZtWF3gvwm9lvgjnFeanC2LGg=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/types": ["@aws-sdk/types@3.821.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-Znroqdai1a90TlxGaJ+FK1lwC0fHpo97Xjsp5UKGR5JODYm7f9+/fF17ebO1KdoBr/Rm0UIFiF5VmI8ts9F1eA=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.27.3", "", {}, "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw=="],
@@ -407,6 +431,18 @@
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.0.4", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.3.1", "@smithy/util-hex-encoding": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-7XoWfZqWb/QoR/rAU4VSi0mWnO2vu9/ltS6JZ5ZSZv0eovLVfDfu0/AX4ub33RsJTOth3TiFWSHS5YdztvFnig=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw=="],
"@smithy/types": ["@smithy/types@4.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.0.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
@@ -425,10 +461,12 @@
"@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.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="],
"@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/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=="],
@@ -473,7 +511,7 @@
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
"ai": ["ai@5.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-alpha.7", "@ai-sdk/provider": "2.0.0-alpha.7", "@ai-sdk/provider-utils": "3.0.0-alpha.7", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-ShCk3frIMdVtK9knvWKiFS7N6Vwnf8mLMv670+T//W9oqfoetSVPBhTF6Dy+oDM/bjVSsBf1BuYImLDvHICOIQ=="],
"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=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -561,7 +599,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.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="],
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -685,6 +723,8 @@
"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=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
@@ -969,10 +1009,16 @@
"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=="],
"lang-map": ["lang-map@0.4.0", "", { "dependencies": { "language-map": "^1.1.0" } }, "sha512-oiSqZIEUnWdFeDNsp4HId4tAxdFbx5iMBOwA3666Fn2L8Khj8NiD9xRvMsGmKXopPVkaDFtSv3CJOmXFUB0Hcg=="],
"language-map": ["language-map@1.5.0", "", {}, "sha512-n7gFZpe+DwEAX9cXVTw43i3wiudWDDtSn28RmdnS/HCPr284dQI/SztsamWanRr75oSlKSaGbV2nmWCTzGCoVg=="],
"leven": ["leven@2.1.0", "", {}, "sha512-nvVPLpIHUxCUoRLrFqTgSxXJ614d8AgQoWl7zPe/2VadE8+1dpU3LBhowRuBAcuwruWtOdD8oYC9jDNJjXDPyA=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
@@ -1273,6 +1319,8 @@
"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=="],
@@ -1353,6 +1401,8 @@
"sax": ["sax@1.2.1", "", {}, "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="],
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
@@ -1405,23 +1455,23 @@
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
"sst": ["sst@3.17.4", "", { "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.4", "sst-darwin-x64": "3.17.4", "sst-linux-arm64": "3.17.4", "sst-linux-x64": "3.17.4", "sst-linux-x86": "3.17.4", "sst-win32-arm64": "3.17.4", "sst-win32-x64": "3.17.4", "sst-win32-x86": "3.17.4" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-WpAws1ASJIilKC9/DGBhZ5wk2I4gtlzHXKpuwPC25bHWjqllv1jZiehIYhhN0PpV2pV8xCvqzyN8Gdm3J4EWQg=="],
"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-darwin-arm64": ["sst-darwin-arm64@3.17.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IJansQWlPdiaQNsJw3FQ+Q/ZXN1hzrq2Q31xG4l2HhA1doj1C3y+6s57vu4cTRDFo2OwBlC4+zlQBJHsOYGhrA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-mHd26/AtaQ79ajqzsutRhgEjkCxX+bXgW4KJIN0AGT3110fo2OL0x2UXmfX+sxSWOFHvJQsjFjFm4CLtQSxyBg=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-unaNWOY3oEI/jUUG47/2Gbreaoi/D/rLsTPeKyYEWhWEBWCojns7LfMQs1bgW0qjBGmazB2IJD4NVYhYqYQxqQ=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
"sst-linux-x64": ["sst-linux-x64@3.17.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zoErI6dVoRxWcmoVVrzNJWKEqfUF/MyQInEkGROGY2YsFFzOM5RD5Dsdm9q6oDGwx+NxFAhQWc8/8C+OmoW1nA=="],
"sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
"sst-linux-x86": ["sst-linux-x86@3.17.4", "", { "os": "linux", "cpu": "none" }, "sha512-7ZHS2rxzxVAxMFW3u5+GMRGGACaBMuLht8JYxqruD8mFVqk9UaPQgrFKIHGKWHLBJLVnF2AdwmlHOcEKP+UJWA=="],
"sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-q4cedr6WD3NqeQkDvmAsIgMgPIjziIWy81wA3ZmnY6UT0jFgFus23ppLIi6F4BFJfOygvAP2PeGrRR3o8giclw=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.4", "", { "os": "win32", "cpu": "x64" }, "sha512-sSQL041YCusZ8/0ynYGe9DCmPYVZOFsemXKUA9tX4IGSDqXae1FN0Sj7HQ17JyY24UUirY1zR7LFk+7KrP6wiA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
"sst-win32-x86": ["sst-win32-x86@3.17.4", "", { "os": "win32", "cpu": "none" }, "sha512-WhjsD2dkA2fbQ03CgwIJb+2p0osll2PTXlr7HC3L+H8wG2DgLFPjoE+6N8n6r2dVMVaDzuNwy/7J8hRB29blaw=="],
"sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
@@ -1453,6 +1503,8 @@
"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=="],
@@ -1461,6 +1513,8 @@
"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=="],
@@ -1471,7 +1525,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.2.4", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-W5mdbcgRpTBDFyEdcU81USs3MFZoXMInpSznc/AFZCwqz8atk4iBNDIlhvihpGHY54Nf5crKmZwJjxVojkHFvA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -1545,6 +1599,8 @@
"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=="],
@@ -1629,12 +1685,24 @@
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
"zod-validation-error": ["zod-validation-error@3.5.2", "", { "peerDependencies": { "zod": "^3.25.0" } }, "sha512-mdi7YOLtram5dzJ5aDtm1AG9+mxRma1iaMrZdYIpFO7epdKBUwLHIxTF8CPDeCQ828zAXYtizrKlEJAtzgfgrw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"@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=="],
"@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=="],
"@aws-crypto/util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@aws-sdk/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/generator/@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=="],
@@ -1655,6 +1723,18 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@smithy/eventstream-codec/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@smithy/is-array-buffer/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@smithy/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@smithy/util-buffer-from/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@smithy/util-hex-encoding/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@smithy/util-utf8/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -1725,6 +1805,8 @@
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1791,6 +1873,8 @@
"wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"args/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
+3 -13
View File
@@ -23,25 +23,15 @@ export const api = new sst.cloudflare.Worker("Api", {
},
])
args.migrations = {
oldTag: "v1",
newTag: "v1",
// Note: when releasing the next tag, make sure all stages use tag v2
oldTag: $app.stage === "production" ? "" : "v1",
newTag: $app.stage === "production" ? "" : "v1",
//newSqliteClasses: ["SyncServer"],
}
},
},
})
// new sst.cloudflare.StaticSite("Web", {
// path: "packages/web",
// domain,
// environment: {
// VITE_API_URL: api.url,
// },
// build: {
// command: "bun run build",
// output: "dist",
// },
// })
new sst.cloudflare.x.Astro("Web", {
domain,
path: "packages/web",
+17 -8
View File
@@ -12,23 +12,28 @@ requested_version=${VERSION:-}
os=$(uname -s | tr '[:upper:]' '[:lower:]')
if [[ "$os" == "darwin" ]]; then
os="mac"
os="darwin"
fi
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
elif [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
filename="$APP-$os-$arch.tar.gz"
filename="$APP-$os-$arch.zip"
case "$filename" in
*"-linux-"*)
[[ "$arch" == "x86_64" || "$arch" == "arm64" || "$arch" == "i386" ]] || exit 1
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
;;
*"-mac-"*)
[[ "$arch" == "x86_64" || "$arch" == "arm64" ]] || exit 1
*"-darwin-"*)
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
;;
*"-windows-"*)
[[ "$arch" == "x64" ]] || exit 1
;;
*)
echo "${RED}Unsupported OS/Arch: $os/$arch${NC}"
@@ -88,8 +93,9 @@ check_version() {
download_and_install() {
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
mkdir -p opencodetmp && cd opencodetmp
curl -# -L $url | tar xz
mv opencode $INSTALL_DIR
curl -# -L -o "$filename" "$url"
unzip -q "$filename"
mv opencode "$INSTALL_DIR"
cd .. && rm -rf opencodetmp
}
@@ -101,7 +107,9 @@ add_to_path() {
local config_file=$1
local command=$2
if [[ -w $config_file ]]; then
if grep -Fxq "$command" "$config_file"; then
print_message info "Command already exists in $config_file, skipping write."
elif [[ -w $config_file ]]; then
echo -e "\n# opencode" >> "$config_file"
echo "$command" >> "$config_file"
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
@@ -167,6 +175,7 @@ if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
*)
export PATH=$INSTALL_DIR:$PATH
print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " export PATH=$INSTALL_DIR:\$PATH"
;;
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://opencode.ai/config.json",
"experimental": {
"hook": {
"file_edited": {
".json": []
},
"session_completed": [
{
"command": ["touch", "./node_modules/foo"]
}
]
}
}
}
+7 -4
View File
@@ -6,7 +6,7 @@
"packageManager": "bun@1.2.14",
"scripts": {
"typecheck": "bun run --filter='*' typecheck",
"dev": "sst dev"
"postinstall": "./scripts/hooks"
},
"workspaces": {
"packages": [
@@ -16,12 +16,12 @@
"typescript": "5.8.2",
"@types/node": "22.13.9",
"zod": "3.24.2",
"ai": "5.0.0-alpha.7"
"ai": "4.3.16"
}
},
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.4"
"sst": "3.17.6"
},
"repository": {
"type": "git",
@@ -38,5 +38,8 @@
"esbuild",
"protobufjs",
"sharp"
]
],
"patchedDependencies": {
"ai@4.3.16": "patches/ai@4.3.16.patch"
}
}
+7 -9
View File
@@ -19,9 +19,9 @@ export class SyncServer extends DurableObject<Env> {
this.ctx.acceptWebSocket(server)
const data = await this.ctx.storage.list()
for (const [key, content] of data.entries()) {
server.send(JSON.stringify({ key, content }))
}
Array.from(data.entries())
.filter(([key, _]) => key.startsWith("session/"))
.map(([key, content]) => server.send(JSON.stringify({ key, content })))
return new Response(null, {
status: 101,
@@ -71,11 +71,9 @@ export class SyncServer extends DurableObject<Env> {
public async getData() {
const data = await this.ctx.storage.list()
const messages = []
for (const [key, content] of data.entries()) {
messages.push({ key, content })
}
return messages
return Array.from(data.entries())
.filter(([key, _]) => key.startsWith("session/"))
.map(([key, content]) => ({ key, content }))
}
private async getSecret() {
@@ -122,7 +120,7 @@ export default {
return new Response(
JSON.stringify({
secret,
url: "https://dev.opencode.ai/s/" + short,
url: "https://opencode.ai/s/" + short,
}),
{
headers: { "Content-Type": "application/json" },
+15 -4
View File
@@ -1,4 +1,4 @@
# OpenCode Agent Guidelines
# opencode agent guidelines
## Build/Test Commands
@@ -7,6 +7,7 @@
- **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
@@ -16,9 +17,19 @@
- **Naming**: camelCase for variables/functions, PascalCase for classes/namespaces
- **Error handling**: Use Result patterns, avoid throwing exceptions in tools
- **File structure**: Namespace-based organization (e.g., `Tool.define()`, `Session.create()`)
## IMPORTANT
- Try to keep things in one function unless composable or reusable
- DO NOT do unnecessary destructuring of variables
- DO NOT use else statements unless necessary
- DO NOT use try catch if it can be avoided
- DO NOT use `else` statements unless necessary
- DO NOT use `try`/`catch` if it can be avoided
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type
- AVOID `let` statements
- PREFER single word variable names where possible
- Use as many bun apis as possible like Bun.file()
## Architecture
@@ -27,4 +38,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.
+56
View File
@@ -0,0 +1,56 @@
@echo off
setlocal enabledelayedexpansion
if defined OPENCODE_BIN_PATH (
set "resolved=%OPENCODE_BIN_PATH%"
goto :execute
)
rem Get the directory of this script
set "script_dir=%~dp0"
set "script_dir=%script_dir:~0,-1%"
rem Detect platform and architecture
set "platform=win32"
rem Detect architecture
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set "arch=x64"
) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set "arch=arm64"
) else if "%PROCESSOR_ARCHITECTURE%"=="x86" (
set "arch=x86"
) else (
set "arch=x64"
)
set "name=opencode-!platform!-!arch!"
set "binary=opencode.exe"
rem Search for the binary starting from script location
set "resolved="
set "current_dir=%script_dir%"
:search_loop
set "candidate=%current_dir%\node_modules\%name%\bin\%binary%"
if exist "%candidate%" (
set "resolved=%candidate%"
goto :execute
)
rem Move up one directory
for %%i in ("%current_dir%") do set "parent_dir=%%~dpi"
set "parent_dir=%parent_dir:~0,-1%"
rem Check if we've reached the root
if "%current_dir%"=="%parent_dir%" goto :not_found
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
exit /b 1
:execute
rem Execute the binary with all arguments
"%resolved%" %*
+372
View File
@@ -0,0 +1,372 @@
{
"type": "object",
"properties": {
"$schema": {
"type": "string",
"description": "JSON schema reference for configuration validation"
},
"theme": {
"type": "string",
"description": "Theme name to use for the interface"
},
"keybinds": {
"type": "object",
"properties": {
"leader": {
"type": "string",
"description": "Leader key for keybind combinations"
},
"help": {
"type": "string",
"description": "Show help dialog"
},
"editor_open": {
"type": "string",
"description": "Open external editor"
},
"session_new": {
"type": "string",
"description": "Create a new session"
},
"session_list": {
"type": "string",
"description": "List all sessions"
},
"session_share": {
"type": "string",
"description": "Share current session"
},
"session_interrupt": {
"type": "string",
"description": "Interrupt current session"
},
"session_compact": {
"type": "string",
"description": "Toggle compact mode for session"
},
"tool_details": {
"type": "string",
"description": "Show tool details"
},
"model_list": {
"type": "string",
"description": "List available models"
},
"theme_list": {
"type": "string",
"description": "List available themes"
},
"project_init": {
"type": "string",
"description": "Initialize project configuration"
},
"input_clear": {
"type": "string",
"description": "Clear input field"
},
"input_paste": {
"type": "string",
"description": "Paste from clipboard"
},
"input_submit": {
"type": "string",
"description": "Submit input"
},
"input_newline": {
"type": "string",
"description": "Insert newline in input"
},
"history_previous": {
"type": "string",
"description": "Navigate to previous history item"
},
"history_next": {
"type": "string",
"description": "Navigate to next history item"
},
"messages_page_up": {
"type": "string",
"description": "Scroll messages up by one page"
},
"messages_page_down": {
"type": "string",
"description": "Scroll messages down by one page"
},
"messages_half_page_up": {
"type": "string",
"description": "Scroll messages up by half page"
},
"messages_half_page_down": {
"type": "string",
"description": "Scroll messages down by half page"
},
"messages_previous": {
"type": "string",
"description": "Navigate to previous message"
},
"messages_next": {
"type": "string",
"description": "Navigate to next message"
},
"messages_first": {
"type": "string",
"description": "Navigate to first message"
},
"messages_last": {
"type": "string",
"description": "Navigate to last message"
},
"app_exit": {
"type": "string",
"description": "Exit the application"
}
},
"additionalProperties": false,
"description": "Custom keybind configurations"
},
"autoshare": {
"type": "boolean",
"description": "Share newly created sessions automatically"
},
"autoupdate": {
"type": "boolean",
"description": "Automatically update to the latest version"
},
"disabled_providers": {
"type": "array",
"items": {
"type": "string"
},
"description": "Disable providers that are loaded automatically"
},
"model": {
"type": "string",
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
},
"provider": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"api": {
"type": "string"
},
"name": {
"type": "string"
},
"env": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string"
},
"npm": {
"type": "string"
},
"models": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
"reasoning": {
"type": "boolean"
},
"temperature": {
"type": "boolean"
},
"tool_call": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
}
},
"required": [
"input",
"output"
],
"additionalProperties": false
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "number"
},
"output": {
"type": "number"
}
},
"required": [
"context",
"output"
],
"additionalProperties": false
},
"id": {
"type": "string"
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"additionalProperties": false
}
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"required": [
"models"
],
"additionalProperties": false
},
"description": "Custom provider configurations and model overrides"
},
"mcp": {
"type": "object",
"additionalProperties": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "local",
"description": "Type of MCP server connection"
},
"command": {
"type": "array",
"items": {
"type": "string"
},
"description": "Command and arguments to run the MCP server"
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Environment variables to set when running the MCP server"
}
},
"required": [
"type",
"command"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "remote",
"description": "Type of MCP server connection"
},
"url": {
"type": "string",
"description": "URL of the remote MCP server"
}
},
"required": [
"type",
"url"
],
"additionalProperties": false
}
]
},
"description": "MCP (Model Context Protocol) server configurations"
},
"experimental": {
"type": "object",
"properties": {
"hook": {
"type": "object",
"properties": {
"file_edited": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
],
"additionalProperties": false
}
}
},
"session_completed": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
],
"additionalProperties": false
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
+13 -8
View File
@@ -1,24 +1,28 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0",
"version": "0.0.5",
"name": "opencode",
"type": "module",
"private": true,
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"dev": "bun run ./src/index.ts"
},
"bin": {
"opencode": "./bin/opencode"
},
"exports": {
"./*": [
"./src/*.ts",
"./src/*/index.ts"
]
"./*": "./src/*.ts"
},
"devDependencies": {
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/anthropic": "1.2.12",
"@tsconfig/bun": "1.0.7",
"@types/bun": "latest",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"typescript": "catalog:"
"typescript": "catalog:",
"zod-to-json-schema": "3.24.5"
},
"dependencies": {
"@clack/prompts": "0.11.0",
@@ -42,6 +46,7 @@
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
"zod-openapi": "4.2.4"
"zod-openapi": "4.2.4",
"zod-validation-error": "3.5.2"
}
}
@@ -1,23 +0,0 @@
# Maintainer: dax
# Maintainer: adam
pkgname='opencode-bin'
pkgver={{VERSION}}
pkgrel=1
pkgdesc='The AI coding agent built for the terminal.'
url='https://github.com/sst/opencode'
arch=('aarch64' 'x86_64')
license=('MIT')
provides=('opencode')
conflicts=('opencode')
depends=('fzf' 'ripgrep')
source_aarch64=("${pkgname}_${pkgver}_aarch64.zip::{{ARM64_URL}}")
sha256sums_aarch64=('{{ARM64_SHA}}')
source_x86_64=("${pkgname}_${pkgver}_x86_64.zip::{{X64_URL}}")
sha256sums_x86_64=('{{X64_SHA}}')
package() {
install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"
}
@@ -80,9 +80,9 @@ function main() {
// Create symlink to the actual binary
fs.symlinkSync(binaryPath, binScript)
console.log(`OpenCode binary symlinked: ${binScript} -> ${binaryPath}`)
console.log(`opencode binary symlinked: ${binScript} -> ${binaryPath}`)
} catch (error) {
console.error("Failed to create OpenCode binary symlink:", error.message)
console.error("Failed to create opencode binary symlink:", error.message)
process.exit(1)
}
}
+108 -27
View File
@@ -64,7 +64,7 @@ for (const [os, arch] of targets) {
await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin`
await $`cp ./script/postinstall.js ./dist/${pkg.name}/postinstall.js`
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
@@ -73,7 +73,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
[pkg.name]: `./bin/${pkg.name}`,
},
scripts: {
postinstall: "node ./postinstall.js",
postinstall: "node ./postinstall.mjs",
},
version,
optionalDependencies,
@@ -108,9 +108,10 @@ if (!snapshot) {
.filter((x: string) => {
const lower = x.toLowerCase()
return (
!lower.includes("chore:") &&
!lower.includes("ignore:") &&
!lower.includes("ci:") &&
!lower.includes("docs:")
!lower.includes("docs:") &&
!lower.includes("doc:")
)
})
.join("\n")
@@ -118,30 +119,52 @@ if (!snapshot) {
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())
// AUR package
const pkgbuildTemplate = await Bun.file("./script/PKGBUILD.template").text()
const pkgbuild = pkgbuildTemplate
.replace("{{VERSION}}", version.split("-")[0])
.replace(
"{{ARM64_URL}}",
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip`,
)
.replace(
"{{ARM64_SHA}}",
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim()),
)
.replace(
"{{X64_URL}}",
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip`,
)
.replace(
"{{X64_SHA}}",
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim()),
)
const pkgbuild = [
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='opencode-bin'",
`pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
"url='https://github.com/sst/opencode'",
"arch=('aarch64' 'x86_64')",
"license=('MIT')",
"provides=('opencode')",
"conflicts=('opencode')",
"depends=('fzf' 'ripgrep')",
"",
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip")`,
`sha256sums_aarch64=('${arm64Sha}')`,
"",
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip")`,
`sha256sums_x86_64=('${x64Sha}')`,
"",
"package() {",
' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
"}",
"",
].join("\n")
await $`rm -rf ./dist/aur-opencode-bin`
@@ -151,4 +174,62 @@ if (!snapshot) {
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`
// Homebrew formula
const homebrewFormula = [
"# typed: false",
"# frozen_string_literal: true",
"",
"# This file was generated by GoReleaser. DO NOT EDIT.",
"class Opencode < Formula",
` desc "The AI coding agent built for the terminal."`,
` homepage "https://github.com/sst/opencode"`,
` version "${version.split("-")[0]}"`,
"",
" on_macos do",
" if Hardware::CPU.intel?",
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-darwin-x64.zip"`,
` sha256 "${macX64Sha}"`,
"",
" def install",
' bin.install "opencode"',
" end",
" end",
" if Hardware::CPU.arm?",
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-darwin-arm64.zip"`,
` sha256 "${macArm64Sha}"`,
"",
" def install",
' bin.install "opencode"',
" end",
" end",
" end",
"",
" on_linux do",
" if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip"`,
` sha256 "${x64Sha}"`,
" def install",
' bin.install "opencode"',
" end",
" end",
" if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip"`,
` sha256 "${arm64Sha}"`,
" def install",
' bin.install "opencode"',
" end",
" end",
" end",
"end",
"",
"",
].join("\n")
await $`rm -rf ./dist/homebrew-tap`
await $`git clone https://${process.env["GITHUB_TOKEN"]}@github.com/sst/homebrew-tap.git ./dist/homebrew-tap`
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
await $`cd ./dist/homebrew-tap && git add opencode.rb`
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/homebrew-tap && git push`
}
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bun
import "zod-openapi/extend"
import { Config } from "../src/config/config"
import { zodToJsonSchema } from "zod-to-json-schema"
const result = zodToJsonSchema(Config.Info)
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
+16 -8
View File
@@ -1,3 +1,4 @@
import "zod-openapi/extend"
import { Log } from "../util/log"
import { Context } from "../util/context"
import { Filesystem } from "../util/filesystem"
@@ -18,6 +19,7 @@ export namespace App {
data: z.string(),
root: z.string(),
cwd: z.string(),
state: z.string(),
}),
time: z.object({
initialized: z.number().optional(),
@@ -32,7 +34,7 @@ export namespace App {
const APP_JSON = "app.json"
async function create(input: { cwd: string; version: string }) {
async function create(input: { cwd: string }) {
log.info("creating", {
cwd: input.cwd,
})
@@ -44,14 +46,12 @@ export namespace App {
const data = path.join(
Global.Path.data,
"project",
git ? git.split(path.sep).join("-") : "global",
git ? directory(git) : "global",
)
const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as {
initialized: number
version: string
}
state.version = input.version
await stateFile.write(JSON.stringify(state))
const services = new Map<
@@ -70,13 +70,13 @@ export namespace App {
git: git !== undefined,
path: {
config: Global.Path.config,
state: Global.Path.state,
data,
root: git ?? input.cwd,
cwd: input.cwd,
},
}
const result = {
version: input.version,
services,
info,
}
@@ -108,7 +108,7 @@ export namespace App {
}
export async function provide<T>(
input: { cwd: string; version: string },
input: { cwd: string },
cb: (app: Info) => Promise<T>,
) {
const app = await create(input)
@@ -124,14 +124,22 @@ export namespace App {
}
export async function initialize() {
const { info, version } = ctx.use()
const { info } = ctx.use()
info.time.initialized = Date.now()
await Bun.write(
path.join(info.path.data, APP_JSON),
JSON.stringify({
version,
initialized: Date.now(),
}),
)
}
function directory(input: string): string {
return input
.split(path.sep)
.filter(Boolean)
.join("-")
.replace(/[^A-Za-z0-9_]/g, "-")
}
}
+5 -3
View File
@@ -1,5 +1,4 @@
import { generatePKCE } from "@openauthjs/openauth/pkce"
import fs from "fs/promises"
import { Auth } from "./index"
export namespace AuthAnthropic {
@@ -9,7 +8,7 @@ export namespace AuthAnthropic {
const pkce = await generatePKCE()
const url = new URL("https://claude.ai/oauth/authorize", import.meta.url)
url.searchParams.set("code", "true")
url.searchParams.set("client_id", "9d1c250a-e61b-44d9-88ed-5944d1962f5e")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("response_type", "code")
url.searchParams.set(
"redirect_uri",
@@ -39,7 +38,7 @@ export namespace AuthAnthropic {
code: splits[0],
state: splits[1],
grant_type: "authorization_code",
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
client_id: CLIENT_ID,
redirect_uri: "https://console.anthropic.com/oauth/code/callback",
code_verifier: verifier,
}),
@@ -49,6 +48,7 @@ export namespace AuthAnthropic {
await Auth.set("anthropic", {
type: "oauth",
refresh: json.refresh_token as string,
access: json.access_token as string,
expires: Date.now() + json.expires_in * 1000,
})
}
@@ -56,6 +56,7 @@ export namespace AuthAnthropic {
export async function access() {
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",
{
@@ -75,6 +76,7 @@ export namespace AuthAnthropic {
await Auth.set("anthropic", {
type: "oauth",
refresh: json.refresh_token as string,
access: json.access_token as string,
expires: Date.now() + json.expires_in * 1000,
})
return json.access_token as string
+20
View File
@@ -0,0 +1,20 @@
import { Global } from "../global"
import { lazy } from "../util/lazy"
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",
)
.then((x) => Bun.write(file, x))
.catch(() => {})
if (!file.exists()) {
const worked = await response
if (!worked) return
}
const result = await import(file.name!).catch(() => {})
if (!result) return
return result.AuthCopilot
})
@@ -0,0 +1,150 @@
import { z } from "zod"
import { Auth } from "./index"
import { NamedError } from "../util/error"
export namespace AuthGithubCopilot {
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
const DEVICE_CODE_URL = "https://github.com/login/device/code"
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
interface DeviceCodeResponse {
device_code: string
user_code: string
verification_uri: string
expires_in: number
interval: number
}
interface AccessTokenResponse {
access_token?: string
error?: string
error_description?: string
}
interface CopilotTokenResponse {
token: string
expires_at: number
refresh_in: number
endpoints: {
api: string
}
}
export async function authorize() {
const deviceResponse = await fetch(DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.26.7",
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "read:user",
}),
})
const deviceData: DeviceCodeResponse = await deviceResponse.json()
return {
device: deviceData.device_code,
user: deviceData.user_code,
verification: deviceData.verification_uri,
interval: deviceData.interval || 5,
expiry: deviceData.expires_in,
}
}
export async function poll(device_code: string) {
const response = await fetch(ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.26.7",
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
})
if (!response.ok) return "failed"
const data: AccessTokenResponse = await response.json()
if (data.access_token) {
// Store the GitHub OAuth token
await Auth.set("github-copilot", {
type: "oauth",
refresh: data.access_token,
access: "",
expires: 0,
})
return "complete"
}
if (data.error === "authorization_pending") return "pending"
if (data.error) return "failed"
return "pending"
}
export async function access() {
const info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
// Get new Copilot API token
const response = await fetch(COPILOT_API_KEY_URL, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${info.refresh}`,
"User-Agent": "GitHubCopilotChat/0.26.7",
"Editor-Version": "vscode/1.99.3",
"Editor-Plugin-Version": "copilot-chat/0.26.7",
},
})
if (!response.ok) return
const tokenData: CopilotTokenResponse = await response.json()
// Store the Copilot API token
await Auth.set("github-copilot", {
type: "oauth",
refresh: info.refresh,
access: tokenData.token,
expires: tokenData.expires_at * 1000,
})
return tokenData.token
}
export const DeviceCodeError = NamedError.create(
"DeviceCodeError",
z.object({}),
)
export const TokenExchangeError = NamedError.create(
"TokenExchangeError",
z.object({
message: z.string(),
}),
)
export const AuthenticationError = NamedError.create(
"AuthenticationError",
z.object({
message: z.string(),
}),
)
export const CopilotTokenError = NamedError.create(
"CopilotTokenError",
z.object({
message: z.string(),
}),
)
}
+1
View File
@@ -7,6 +7,7 @@ export namespace Auth {
export const Oauth = z.object({
type: z.literal("oauth"),
refresh: z.string(),
access: z.string(),
expires: z.number(),
})
+45 -1
View File
@@ -1,4 +1,9 @@
import { z } from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { NamedError } from "../util/error"
export namespace BunProc {
const log = Log.create({ service: "bun" })
@@ -8,7 +13,7 @@ export namespace BunProc {
) {
log.info("running", {
cmd: [which(), ...cmd],
options,
...options,
})
const result = Bun.spawn([which(), ...cmd], {
...options,
@@ -21,6 +26,15 @@ export namespace BunProc {
},
})
const code = await result.exited
// @ts-ignore
const stdout = await result.stdout.text()
// @ts-ignore
const stderr = await result.stderr.text()
log.info("done", {
code,
stdout,
stderr,
})
if (code !== 0) {
throw new Error(`Command failed with exit code ${result.exitCode}`)
}
@@ -30,4 +44,34 @@ export namespace BunProc {
export function which() {
return process.execPath
}
export const InstallFailedError = NamedError.create(
"BunInstallFailedError",
z.object({
pkg: z.string(),
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
const parsed = await pkgjson.json().catch(() => ({
dependencies: {},
}))
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"], {
cwd: Global.Path.cache,
}).catch((e) => {
new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
return mod
}
}
+133 -19
View File
@@ -1,10 +1,15 @@
import { AuthAnthropic } from "../../auth/anthropic"
import { AuthCopilot } from "../../auth/copilot"
import { Auth } from "../../auth"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import open from "open"
import { UI } from "../ui"
import { ModelsDev } from "../../provider/models"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Global } from "../../global"
export const AuthCommand = cmd({
command: "auth",
@@ -15,7 +20,7 @@ export const AuthCommand = cmd({
.command(AuthLogoutCommand)
.command(AuthListCommand)
.demandCommand(),
async handler(args) {},
async handler() {},
})
export const AuthListCommand = cmd({
@@ -24,45 +29,110 @@ export const AuthListCommand = cmd({
describe: "list providers",
async handler() {
UI.empty()
prompts.intro("Credentials")
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
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()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}(${result.type})`)
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
prompts.outro(`${results.length} credentials`)
// Environment variables section
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
})
}
}
}
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`)
}
},
})
export const AuthLoginCommand = cmd({
command: "login",
describe: "login to a provider",
describe: "log in to a provider",
async handler() {
UI.empty()
prompts.intro("Add credential")
const provider = await prompts.select({
const providers = await ModelsDev.get()
const priority: Record<string, number> = {
anthropic: 0,
"github-copilot": 1,
openai: 2,
google: 3,
}
let provider = await prompts.select({
message: "Select provider",
maxItems: 2,
maxItems: 8,
options: [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] === 0 ? "recommended" : undefined,
})),
),
{
label: "Anthropic",
value: "anthropic",
},
{
label: "OpenAI",
value: "openai",
},
{
label: "Google",
value: "google",
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
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\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon bedrock can be configured with standard AWS environment variables like AWS_PROFILE or AWS_ACCESS_KEY_ID",
)
prompts.outro("Done")
return
}
if (provider === "anthropic") {
const method = await prompts.select({
message: "Login method",
@@ -83,8 +153,14 @@ export const AuthLoginCommand = cmd({
// some weird bug where program exits without this
await new Promise((resolve) => setTimeout(resolve, 10))
const { url, verifier } = await AuthAnthropic.authorize()
prompts.note("Opening browser...")
await open(url)
prompts.note("Trying to open browser...")
try {
await open(url)
} catch (e) {
prompts.log.error(
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
)
}
prompts.log.info(url)
const code = await prompts.text({
@@ -105,6 +181,44 @@ export const AuthLoginCommand = cmd({
}
}
const copilot = await AuthCopilot()
if (provider === "github-copilot" && copilot) {
await new Promise((resolve) => setTimeout(resolve, 10))
const deviceInfo = await copilot.authorize()
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),
)
const response = await copilot.poll(deviceInfo.device)
if (response.status === "pending") continue
if (response.status === "success") {
await Auth.set("github-copilot", {
type: "oauth",
refresh: response.refresh,
access: response.access,
expires: response.expires,
})
spinner.stop("Login successful")
break
}
if (response.status === "failed") {
spinner.stop("Failed to authorize", 1)
break
}
}
prompts.outro("Done")
return
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x.length > 0 ? undefined : "Required"),
@@ -121,7 +235,7 @@ export const AuthLoginCommand = cmd({
export const AuthLogoutCommand = cmd({
command: "logout",
describe: "logout from a configured provider",
describe: "log out from a configured provider",
async handler() {
UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x))
@@ -1,20 +0,0 @@
import { AuthAnthropic } from "../../auth/anthropic"
import { UI } from "../ui"
// Example: https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=MdFtFgFap23AWDSN0oa3-eaKjQRFE4CaEhXx8M9fHZg&code_challenge_method=S256&state=rKLtaDzm88GSwekyEqdi0wXX-YqIr13tSzYymSzpvfs
export const LoginAnthropicCommand = {
command: "anthropic",
describe: "Login to Anthropic",
handler: async () => {
const { url, verifier } = await AuthAnthropic.authorize()
UI.println("Login to Anthropic")
UI.println("Open the following URL in your browser:")
UI.println(url)
UI.println("")
const code = await UI.input("Paste the authorization code here: ")
await AuthAnthropic.exchange(code, verifier)
},
}
+19
View File
@@ -0,0 +1,19 @@
import { App } from "../../app/app"
import { Provider } from "../../provider/provider"
import { cmd } from "./cmd"
export const ModelsCommand = cmd({
command: "models",
describe: "list all available models",
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}`)
}
}
})
},
})
+78 -38
View File
@@ -6,68 +6,104 @@ import { Session } from "../../session"
import { Share } from "../../share/share"
import { Message } from "../../session/message"
import { UI } from "../ui"
import { VERSION } from "../version"
const COLOR = [
UI.Style.TEXT_SUCCESS_BOLD,
UI.Style.TEXT_INFO_BOLD,
UI.Style.TEXT_HIGHLIGHT_BOLD,
UI.Style.TEXT_WARNING_BOLD,
]
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
const TOOL: Record<string, [string, string]> = {
opencode_todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
opencode_todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
opencode_bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
opencode_edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
opencode_glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
opencode_grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
opencode_list: ["List", UI.Style.TEXT_INFO_BOLD],
opencode_read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
opencode_write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
list: ["List", UI.Style.TEXT_INFO_BOLD],
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
}
export const RunCommand = {
export const RunCommand = cmd({
command: "run [message..]",
describe: "Run OpenCode with a message",
describe: "run opencode with a message",
builder: (yargs: Argv) => {
return yargs
.positional("message", {
describe: "Message to send",
describe: "message to send",
type: "string",
array: true,
default: [],
})
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
describe: "Session ID to continue",
alias: ["s"],
describe: "session id to continue",
type: "string",
})
.option("share", {
type: "boolean",
describe: "share the session",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
},
handler: async (args: {
message: string[]
session?: string
printLogs?: boolean
}) => {
handler: async (args) => {
const message = args.message.join(" ")
await App.provide(
{
cwd: process.cwd(),
version: VERSION,
},
async () => {
await Share.init()
const session = args.session
? await Session.get(args.session)
: await Session.create()
const session = await (async () => {
if (args.continue) {
const first = await Session.list().next()
if (first.done) return
return first.value
}
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
if (args.session) return Session.get(args.session)
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
}
const isPiped = !process.stdout.isTTY
UI.empty()
UI.println(UI.logo())
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
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_INFO_BOLD +
"~ https://dev.opencode.ai/s/" +
session.id.slice(-8),
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
)
UI.empty()
@@ -100,7 +136,7 @@ export const RunCommand = {
part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD,
]
printEvent(color, tool, metadata.title)
printEvent(color, tool, metadata?.title || "Unknown")
}
if (part.type === "text") {
@@ -114,8 +150,7 @@ export const RunCommand = {
}
})
const { providerID, modelID } = await Provider.defaultModel()
await Session.chat({
const result = await Session.chat({
sessionID: session.id,
providerID,
modelID,
@@ -126,8 +161,13 @@ export const RunCommand = {
},
],
})
if (isPiped) {
const match = result.parts.findLast((x) => x.type === "text")
if (match) process.stdout.write(match.text)
}
UI.empty()
},
)
},
}
})
+9 -5
View File
@@ -1,14 +1,18 @@
import { App } from "../../app/app"
import { VERSION } from "../version"
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() {
await App.provide({ cwd: process.cwd(), version: VERSION }, async (app) => {
Bun.resolveSync("typescript/lib/tsserver.js", app.path.cwd)
})
async handler(args) {
await App.provide(
{ cwd: process.cwd() },
async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
},
)
},
})
+50
View File
@@ -0,0 +1,50 @@
import { App } from "../../app/app"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { Share } from "../../share/share"
import { cmd } from "./cmd"
export const ServeCommand = cmd({
command: "serve",
builder: (yargs) =>
yargs
.option("port", {
alias: ["p"],
type: "number",
describe: "port to listen on",
default: 4096,
})
.option("hostname", {
alias: ["h"],
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
describe: "starts a headless opencode server",
handler: async (args) => {
const cwd = process.cwd()
await App.provide({ cwd }, async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
const hostname = args.hostname
const port = args.port
await Share.init()
const server = Server.listen({
port,
hostname,
})
console.log(
`opencode server listening on http://${server.hostname}:${server.port}`,
)
await new Promise(() => {})
server.stop()
})
},
})
+53
View File
@@ -0,0 +1,53 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
export const UpgradeCommand = {
command: "upgrade [target]",
describe: "upgrade opencode to the latest or a specific version",
builder: (yargs: Argv) => {
return yargs
.positional("target", {
describe: "version to upgrade to, for ex '0.1.48' or 'v0.1.48'",
type: "string",
})
.option("method", {
alias: "m",
describe: "installation method to use",
type: "string",
choices: ["curl", "npm", "pnpm", "bun", "brew"],
})
},
handler: async (args: { target?: string; method?: string }) => {
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Upgrade")
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.outro("Done")
return
}
prompts.log.info("Using method: " + method)
const target = args.target ?? (await Installation.latest())
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)
else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")
return
}
spinner.stop("Upgrade complete")
prompts.outro("Done")
},
}
+19
View File
@@ -0,0 +1,19 @@
import { Config } from "../config/config"
import { MCP } from "../mcp"
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.InvalidError.isInstance(input))
return [
`Config file at ${input.data.path} is invalid`,
...(input.data.issues?.map(
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []),
].join("\n")
if (UI.CancelledError.isInstance(input)) return ""
}
-193
View File
@@ -1,193 +0,0 @@
import { createCli, type TrpcCliMeta } from "trpc-cli"
import { initTRPC } from "@trpc/server"
import { z } from "zod"
import { Server } from "../server/server"
import { AuthAnthropic } from "../auth/anthropic"
import { UI } from "./ui"
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 { VERSION } from "./version"
import { LSP } from "../lsp"
import fs from "fs/promises"
import path from "path"
const t = initTRPC.meta<TrpcCliMeta>().create()
export const router = t.router({
generate: t.procedure
.meta({
description: "Generate OpenAPI and event specs",
})
.input(z.object({}))
.mutation(async () => {
const specs = await Server.openapi()
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),
)
return "Generated OpenAPI specs in gen/ directory"
}),
run: t.procedure
.meta({
description: "Run OpenCode with a message",
})
.input(
z.object({
message: z.array(z.string()).default([]).describe("Message to send"),
session: z.string().optional().describe("Session ID to continue"),
}),
)
.mutation(
async ({ input }: { input: { message: string[]; session?: string } }) => {
const message = input.message.join(" ")
await App.provide(
{
cwd: process.cwd(),
version: "0.0.0",
},
async () => {
await Share.init()
const session = input.session
? await Session.get(input.session)
: await Session.create()
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://dev.opencode.ai/s?id=" +
session.id.slice(-8),
)
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(Message.Event.PartUpdated, async (message) => {
const part = message.properties.part
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
if (part.toolInvocation.toolName === "opencode_todowrite")
return
const args = part.toolInvocation.args as any
const tool = part.toolInvocation.toolName
if (tool === "opencode_edit")
printEvent(UI.Style.TEXT_SUCCESS_BOLD, "Edit", args.filePath)
if (tool === "opencode_bash")
printEvent(
UI.Style.TEXT_WARNING_BOLD,
"Execute",
args.command,
)
if (tool === "opencode_read")
printEvent(UI.Style.TEXT_INFO_BOLD, "Read", args.filePath)
if (tool === "opencode_write")
printEvent(
UI.Style.TEXT_SUCCESS_BOLD,
"Create",
args.filePath,
)
if (tool === "opencode_list")
printEvent(UI.Style.TEXT_INFO_BOLD, "List", args.path)
if (tool === "opencode_glob")
printEvent(
UI.Style.TEXT_INFO_BOLD,
"Glob",
args.pattern + (args.path ? " in " + args.path : ""),
)
}
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)
}
})
const { providerID, modelID } = await Provider.defaultModel()
await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
UI.empty()
},
)
return "Session completed"
},
),
scrap: t.procedure
.meta({
description: "Test command for scraping files",
})
.input(
z.object({
file: z.string().describe("File to process"),
}),
)
.mutation(async ({ input }: { input: { file: string } }) => {
await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
await LSP.touchFile(input.file, true)
await LSP.diagnostics()
})
return `Processed file: ${input.file}`
}),
login: t.router({
anthropic: t.procedure
.meta({
description: "Login to Anthropic",
})
.input(z.object({}))
.mutation(async () => {
const { url, verifier } = await AuthAnthropic.authorize()
UI.println("Login to Anthropic")
UI.println("Open the following URL in your browser:")
UI.println(url)
UI.println("")
const code = await UI.input("Paste the authorization code here: ")
await AuthAnthropic.exchange(code, verifier)
return "Successfully logged in to Anthropic"
}),
}),
})
export function createOpenCodeCli() {
return createCli({ router })
}
+14 -11
View File
@@ -1,11 +1,12 @@
import { z } from "zod"
import { EOL } from "os"
import { NamedError } from "../util/error"
export namespace UI {
const LOGO = [
`█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀`,
`█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀`,
`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`,
[`█▀▀█ █▀▀█ █▀▀ █▀▀▄ `, `█▀▀ █▀▀█ █▀▀▄ █▀▀`],
[`█░░█ █░░█ █▀▀ █░░█ `, `█░░ █░░█ █░░█ █▀▀`],
[`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `, `▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`],
]
export const CancelledError = NamedError.create("UICancelledError", z.void())
@@ -29,7 +30,7 @@ export namespace UI {
export function println(...message: string[]) {
print(...message)
Bun.stderr.write("\n")
Bun.stderr.write(EOL)
}
export function print(...message: string[]) {
@@ -48,13 +49,11 @@ export namespace UI {
const result = []
for (const row of LOGO) {
if (pad) result.push(pad)
for (let i = 0; i < row.length; i++) {
const color =
i > 18 ? Bun.color("white", "ansi") : Bun.color("gray", "ansi")
const char = row[i]
result.push(color + char)
}
result.push("\n")
result.push(Bun.color("gray", "ansi"))
result.push(row[0])
result.push("\x1b[0m")
result.push(row[1])
result.push(EOL)
}
return result.join("").trimEnd()
}
@@ -73,4 +72,8 @@ export namespace UI {
})
})
}
export function error(message: string) {
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
}
-6
View File
@@ -1,6 +0,0 @@
declare global {
const OPENCODE_VERSION: string
}
export const VERSION =
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
+228 -36
View File
@@ -1,66 +1,258 @@
import { Log } from "../util/log"
import path from "path"
import { z } from "zod"
import { App } from "../app/app"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
import { NamedError } from "../util/error"
export namespace Config {
const log = Log.create({ service: "config" })
export const state = App.state("config", async (app) => {
let result: Info = {}
let result = await global()
for (const file of ["opencode.jsonc", "opencode.json"]) {
const [resolved] = await Filesystem.findUp(
file,
app.path.cwd,
app.path.root,
)
if (!resolved) continue
try {
result = await import(resolved).then((mod) => Info.parse(mod.default))
log.info("found", { path: resolved })
break
} catch (e) {
if (e instanceof z.ZodError) {
for (const issue of e.issues) {
log.info(issue.message)
}
throw e
}
continue
const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
for (const resolved of found.toReversed()) {
result = mergeDeep(result, await load(resolved))
}
}
log.info("loaded", result)
return result
})
export const McpLocal = z.object({
type: z.literal("local"),
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
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"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
})
.strict()
.openapi({
ref: "Config.McpLocal",
})
export const McpRemote = z.object({
type: z.literal("remote"),
url: z.string(),
})
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"),
})
.strict()
.openapi({
ref: "Config.McpRemote",
})
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>
export const Info = z
export const Keybinds = z
.object({
provider: z.record(z.string(), z.record(z.string(), z.any())).optional(),
tool: z
.object({
provider: z.record(z.string(), z.string().array()).optional(),
})
.optional(),
mcp: z.record(z.string(), Mcp).optional(),
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"),
messages_half_page_down: z
.string()
.optional()
.describe("Scroll messages down by half page"),
messages_previous: z
.string()
.optional()
.describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
messages_first: z
.string()
.optional()
.describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
app_exit: z.string().optional().describe("Exit the application"),
})
.strict()
.openapi({
ref: "Config.Keybinds",
})
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"),
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(),
provider: z
.record(
ModelsDev.Provider.partial().extend({
models: z.record(ModelsDev.Model.partial()),
options: z.record(z.any()).optional(),
}),
)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z
.record(z.string(), Mcp)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
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",
})
export type Info = z.output<typeof Info>
export const global = lazy(async () => {
let result = await load(path.join(Global.Path.config, "config.json"))
await import(path.join(Global.Path.config, "config"), {
with: {
type: "toml",
},
})
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
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 fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})
return result
})
async function load(path: string) {
const data = await Bun.file(path)
.json()
.catch((err) => {
if (err.code === "ENOENT") return {}
throw new JsonError({ path }, { cause: err })
})
const parsed = Info.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({ path, issues: parsed.error.issues })
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.ZodIssue[]>().optional(),
}),
)
export function get() {
return state()
}
+16 -12
View File
@@ -1,4 +1,3 @@
import { App } from "../app/app"
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
@@ -6,6 +5,7 @@ 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" })
@@ -116,19 +116,23 @@ export namespace Fzf {
return filepath
}
export async function search(cwd: string, query: string) {
const process = Bun.spawn({
cwd,
stdin: "inherit",
stdout: "pipe",
stderr: "pipe",
cmd: [await filepath(), "--filter", query],
})
await process.exited
const stdout = await Bun.readableStreamToText(process.stdout)
return stdout
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
}
}
+19 -1
View File
@@ -1,10 +1,11 @@
import { App } from "../app/app"
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 = {
@@ -111,4 +112,21 @@ export namespace Ripgrep {
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)
}
}
-10
View File
@@ -1,10 +0,0 @@
export namespace File {
const glob = new Bun.Glob("**/*")
export async function search(path: string, query: string) {
for await (const entry of glob.scan({
cwd: path,
onlyFiles: true,
})) {
}
}
}
+8
View File
@@ -0,0 +1,8 @@
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
}
}
+143
View File
@@ -0,0 +1,143 @@
import { App } from '../app/app'
import { BunProc } from '../bun'
import { Config } from '../config/config'
import { Log } from '../util/log'
import path from 'path'
export namespace Format {
const log = Log.create({ service: 'format' })
const state = App.state('format', async () => {
const hooks: Record<string, Hook[]> = {}
for (const item of FORMATTERS) {
if (await item.enabled()) {
for (const ext of item.extensions) {
const list = hooks[ext] ?? []
list.push({
command: item.command,
environment: item.environment,
})
hooks[ext] = list
}
}
}
const cfg = await Config.get()
for (const [file, items] of Object.entries(
cfg.experimental?.hook?.file_edited ?? {},
)) {
for (const item of items) {
const list = hooks[file] ?? []
list.push({
command: item.command,
environment: item.environment,
})
hooks[file] = list
}
}
return {
hooks,
}
})
export async function run(file: string) {
log.info('formatting', { file })
const { hooks } = await state()
const ext = path.extname(file)
const match = hooks[ext]
if (!match) return
for (const item of match) {
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,
})
const exit = await proc.exited
if (exit !== 0)
log.error('failed', {
command: item.command,
...item.environment,
})
}
}
interface Hook {
command: string[]
environment?: Record<string, string>
}
interface Native {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
}
const FORMATTERS: Native[] = [
{
name: 'prettier',
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',
'.php',
'.rb',
'.java',
'.go',
'.rs',
'.swift',
'.kt',
'.kts',
'.sol',
'.graphql',
'.gql',
],
command: [BunProc.which(), 'run', 'prettier', '--write', '$FILE'],
environment: {
BUN_BE_BUN: '1',
},
async enabled() {
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
}
},
},
]
}
+12 -7
View File
@@ -1,5 +1,5 @@
import fs from "fs/promises"
import { xdgData, xdgCache, xdgConfig } from "xdg-basedir"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import path from "path"
const app = "opencode"
@@ -7,18 +7,23 @@ const app = "opencode"
const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app)
const config = path.join(xdgConfig!, app)
await Promise.all([
fs.mkdir(data, { recursive: true }),
fs.mkdir(config, { recursive: true }),
fs.mkdir(cache, { recursive: true }),
])
const state = path.join(xdgState!, app)
export namespace Global {
export const Path = {
data,
bin: path.join(data, "bin"),
providers: path.join(config, "providers"),
cache,
config,
state,
} as const
}
await Promise.all([
fs.mkdir(Global.Path.data, { recursive: true }),
fs.mkdir(Global.Path.config, { recursive: true }),
fs.mkdir(Global.Path.cache, { recursive: true }),
fs.mkdir(Global.Path.providers, { recursive: true }),
fs.mkdir(Global.Path.state, { recursive: true }),
])
+12 -4
View File
@@ -41,6 +41,17 @@ export namespace Identifier {
return given
}
function randomBase62(length: number): string {
const chars =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
function generateNewID(
prefix: keyof typeof prefixes,
descending: boolean,
@@ -62,14 +73,11 @@ export namespace Identifier {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
const randLength = (LENGTH - 12) / 2
const random = randomBytes(randLength)
return (
prefixes[prefix] +
"_" +
timeBytes.toString("hex") +
random.toString("hex")
randomBase62(LENGTH - 12)
)
}
}
+129 -56
View File
@@ -3,40 +3,61 @@ 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 { VERSION } from "./cli/version"
import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } 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"
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")
.version(VERSION)
.help("help", "show help")
.version("version", "show version number", Installation.VERSION)
.alias("version", "v")
.option("print-logs", {
describe: "Print logs to stderr",
describe: "print logs to stderr",
type: "boolean",
})
.middleware(async () => {
await Log.init({ print: process.argv.includes("--print-logs") })
Log.Default.info("opencode", {
version: VERSION,
version: Installation.VERSION,
args: process.argv.slice(2),
})
})
.usage("\n" + UI.logo())
.command({
command: "$0 [project]",
describe: "Start OpenCode TUI",
describe: "start opencode tui",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
@@ -45,52 +66,82 @@ const cli = yargs(hideBin(process.argv))
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
process.chdir(cwd)
const result = await App.provide(
{ cwd, version: VERSION },
async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
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"
}
await Share.init()
const server = Server.listen()
let cmd = ["go", "run", "./main.go"]
let cwd = new URL("../../tui/cmd/opencode", import.meta.url)
.pathname
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
const binary = path.join(Global.Path.cache, "tui", blob.name)
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 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)
}
const proc = Bun.spawn({
cmd,
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
},
onExit: () => {
server.stop()
},
})
await proc.exited
await server.stop()
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()
},
})
return "done"
},
)
;(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()
@@ -105,21 +156,43 @@ const cli = yargs(hideBin(process.argv))
.command(GenerateCommand)
.command(ScrapCommand)
.command(AuthCommand)
.fail((msg, err) => {
.command(UpgradeCommand)
.command(ServeCommand)
.command(ModelsCommand)
.fail((msg) => {
if (
msg.startsWith("Unknown argument") ||
msg.startsWith("Not enough non-option arguments")
) {
cli.showHelp("log")
}
Log.Default.error(msg, {
err,
})
})
.strict()
try {
await cli.parse()
} catch (e) {
Log.Default.error(e)
const 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,
message: e.message,
cause: e.cause?.toString(),
})
}
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",
)
}
cancel.abort()
+146
View File
@@ -0,0 +1,146 @@
import path from "path"
import { $ } from "bun"
import { z } from "zod"
import { NamedError } from "../util/error"
import { Bus } from "../bus"
import { Log } from "../util/log"
declare global {
const OPENCODE_VERSION: string
}
export namespace Installation {
const log = Log.create({ service: "installation" })
export type Method = Awaited<ReturnType<typeof method>>
export const Event = {
Updated: Bus.event(
"installation.updated",
z.object({
version: z.string(),
}),
),
}
export const Info = z
.object({
version: z.string(),
latest: z.string(),
})
.openapi({
ref: "InstallationInfo",
})
export type Info = z.infer<typeof Info>
export async function info() {
return {
version: VERSION,
latest: await latest(),
}
}
export function isSnapshot() {
return VERSION.startsWith("0.0.0")
}
export function isDev() {
return VERSION === "dev"
}
export async function method() {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
const exec = process.execPath.toLowerCase()
const checks = [
{
name: "npm" as const,
command: () => $`npm list -g --depth=0`.throws(false).text(),
},
{
name: "yarn" as const,
command: () => $`yarn global list`.throws(false).text(),
},
{
name: "pnpm" as const,
command: () => $`pnpm list -g --depth=0`.throws(false).text(),
},
{
name: "bun" as const,
command: () => $`bun pm ls -g`.throws(false).text(),
},
{
name: "brew" as const,
command: () => $`brew list --formula opencode-ai`.throws(false).text(),
},
]
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = await check.command()
if (output.includes("opencode-ai")) {
return check.name
}
}
return "unknown"
}
export const UpgradeFailedError = NamedError.create(
"UpgradeFailedError",
z.object({
stderr: z.string(),
}),
)
export async function upgrade(method: Method, target: string) {
const cmd = (() => {
switch (method) {
case "curl":
return $`curl -fsSL https://opencode.ai/install | bash`.env({
...process.env,
VERSION: target,
})
case "npm":
return $`npm install -g opencode-ai@${target}`
case "pnpm":
return $`pnpm install -g opencode-ai@${target}`
case "bun":
return $`bun install -g opencode-ai@${target}`
case "brew":
return $`brew install sst/tap/opencode`.env({
HOMEBREW_NO_AUTO_UPDATE: "1",
})
default:
throw new Error(`Unknown method: ${method}`)
}
})()
const result = await cmd.quiet().throws(false)
log.info("upgraded", {
method,
target,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
if (result.exitCode !== 0)
throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"),
})
}
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")
.then((res) => res.json())
.then((data) => data.tag_name.slice(1) as string)
}
}
+39 -23
View File
@@ -11,6 +11,7 @@ import { LANGUAGE_EXTENSIONS } from "./language"
import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "../util/error"
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
@@ -19,6 +20,13 @@ export namespace LSPClient {
export type Diagnostic = VSCodeDiagnostic
export const InitializeError = NamedError.create(
"LSPInitializeError",
z.object({
serverID: z.string(),
}),
)
export const Event = {
Diagnostics: Bus.event(
"lsp.client.diagnostics",
@@ -52,32 +60,40 @@ export namespace LSPClient {
})
connection.listen()
await connection.sendRequest("initialize", {
processId: server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.path.cwd,
},
],
initializationOptions: {
...server.initialization,
},
capabilities: {
workspace: {
configuration: true,
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
log.info("sending initialize", { id: serverID })
await Promise.race([
connection.sendRequest("initialize", {
processId: server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.path.cwd,
},
publishDiagnostics: {
versionSupport: true,
],
initializationOptions: {
...server.initialization,
},
capabilities: {
workspace: {
configuration: true,
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
},
publishDiagnostics: {
versionSupport: true,
},
},
},
},
})
}),
new Promise((_, reject) => {
setTimeout(() => {
reject(new InitializeError({ serverID }))
}, 5_000)
}),
])
await connection.sendNotification("initialized", {})
log.info("initialized")
+12 -3
View File
@@ -12,9 +12,10 @@ export namespace LSP {
async () => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
const skip = new Set<string>()
return {
clients,
skip,
}
},
async (state) => {
@@ -31,11 +32,19 @@ export namespace LSP {
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) continue
const client = await LSPClient.create(match.id, handle)
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) {
+3
View File
@@ -70,6 +70,9 @@ export namespace LSPServer {
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) {
+48 -5
View File
@@ -2,8 +2,22 @@ import { experimental_createMCPClient, type Tool } from "ai"
import { Experimental_StdioMCPTransport } from "ai/mcp-stdio"
import { App } from "../app/app"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
export namespace MCP {
const log = Log.create({ service: "mcp" })
export const Failed = NamedError.create(
"MCPFailed",
z.object({
name: z.string(),
}),
)
const state = App.state(
"mcp",
async () => {
@@ -12,27 +26,56 @@ export namespace MCP {
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
} = {}
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
log.info("found", { key, type: mcp.type })
if (mcp.type === "remote") {
clients[key] = await experimental_createMCPClient({
const client = await experimental_createMCPClient({
name: key,
transport: {
type: "sse",
url: mcp.url,
},
})
}).catch(() => {})
if (!client) {
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
data: {
message: `MCP server ${key} failed to start`,
},
},
})
continue
}
clients[key] = client
}
if (mcp.type === "local") {
const [cmd, ...args] = mcp.command
clients[key] = await experimental_createMCPClient({
const client = await experimental_createMCPClient({
name: key,
transport: new Experimental_StdioMCPTransport({
stderr: "ignore",
command: cmd,
args,
env: mcp.environment,
env: {
...process.env,
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
},
}),
})
}).catch(() => {})
if (!client) {
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
data: {
message: `MCP server ${key} failed to start`,
},
},
})
continue
}
clients[key] = client
}
}
@@ -64,6 +64,7 @@ export namespace Permission {
title: Info["title"]
metadata: Info["metadata"]
}) {
return
const { pending, approved } = state()
log.info("asking", {
sessionID: input.sessionID,
@@ -0,0 +1,4 @@
export async function data() {
const json = await fetch("https://models.dev/api.json").then((x) => x.text())
return json
}
+48 -7
View File
@@ -1,28 +1,69 @@
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { z } from "zod"
import { data } from "./models-macro" with { type: "macro" }
export namespace ModelsDev {
const log = Log.create({ service: "models.dev" })
const filepath = path.join(Global.Path.cache, "models.json")
export const Model = z
.object({
name: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
cost: z.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
}),
limit: z.object({
context: z.number(),
output: z.number(),
}),
id: z.string(),
options: z.record(z.any()),
})
.openapi({
ref: "Model.Info",
})
export type Model = z.infer<typeof Model>
export const Provider = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
models: z.record(Model),
})
.openapi({
ref: "Provider.Info",
})
export type Provider = z.infer<typeof Provider>
export async function get() {
const file = Bun.file(filepath)
const result = await file.json().catch(() => {})
if (result) {
refresh()
return result
return result as Record<string, Provider>
}
await refresh()
return get()
refresh()
const json = await data()
return JSON.parse(json) as Record<string, Provider>
}
async function refresh() {
const file = Bun.file(filepath)
log.info("refreshing")
const result = await fetch("https://models.dev/api.json")
if (!result.ok)
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
await Bun.write(file, result)
const result = await fetch("https://models.dev/api.json").catch(() => {})
if (result && result.ok) await Bun.write(file, result)
}
}
+284 -130
View File
@@ -4,8 +4,6 @@ import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda"
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import path from "path"
import { Global } from "../global"
import { BunProc } from "../bun"
import { BashTool } from "../tool/bash"
import { EditTool } from "../tool/edit"
@@ -21,160 +19,268 @@ import type { Tool } from "../tool/tool"
import { WriteTool } from "../tool/write"
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
import { AuthAnthropic } from "../auth/anthropic"
import { AuthCopilot } from "../auth/copilot"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { TaskTool } from "../tool/task"
export namespace Provider {
const log = Log.create({ service: "provider" })
export const Model = z
.object({
id: z.string(),
name: z.string().optional(),
attachment: z.boolean(),
reasoning: z.boolean().optional(),
cost: z.object({
input: z.number(),
inputCached: z.number(),
output: z.number(),
outputCached: z.number(),
}),
limit: z.object({
context: z.number(),
output: z.number(),
}),
})
.openapi({
ref: "Provider.Model",
})
export type Model = z.output<typeof Model>
type CustomLoader = (
provider: ModelsDev.Provider,
api?: string,
) => Promise<{
autoload: boolean
getModel?: (sdk: any, modelID: string) => Promise<any>
options?: Record<string, any>
}>
export const Info = z
.object({
id: z.string(),
name: z.string(),
models: z.record(z.string(), Model),
})
.openapi({
ref: "Provider.Info",
})
export type Info = z.output<typeof Info>
type Source = "env" | "config" | "custom" | "api"
type Autodetector = (provider: Info) => Promise<
| {
source: Source
options: Record<string, any>
}
| false
>
function env(...keys: string[]) {
const result: Autodetector = async () => {
for (const key of keys) {
if (process.env[key])
return {
source: "env",
options: {},
}
}
return false
}
return result
}
type Source = "oauth" | "env" | "config" | "api"
const AUTODETECT: Record<string, Autodetector> = {
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
async anthropic(provider) {
const access = await AuthAnthropic.access()
if (access) {
// claude sub doesn't have usage cost
if (!access) return { autoload: false }
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
}
}
return {
autoload: true,
options: {
apiKey: "",
async fetch(input: any, init: any) {
const access = await AuthAnthropic.access()
const headers = {
...init.headers,
authorization: `Bearer ${access}`,
"anthropic-beta": "oauth-2025-04-20",
}
delete headers["x-api-key"]
return fetch(input, {
...init,
headers,
})
},
},
}
},
"github-copilot": async (provider) => {
const copilot = await AuthCopilot()
if (!copilot) return { autoload: false }
let info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return { autoload: false }
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
inputCached: 0,
output: 0,
outputCached: 0,
}
}
return {
source: "oauth",
options: {
apiKey: "",
headers: {
authorization: `Bearer ${access}`,
"anthropic-beta": "oauth-2025-04-20",
},
},
}
}
return env("ANTHROPIC_API_KEY")(provider)
return {
autoload: true,
options: {
apiKey: "",
async fetch(input: any, init: any) {
const info = await Auth.get("github-copilot")
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")
await Auth.set("github-copilot", {
type: "oauth",
...tokens,
})
info.access = tokens.access
}
const headers = {
...init.headers,
...copilot.HEADERS,
Authorization: `Bearer ${info.access}`,
"Openai-Intent": "conversation-edits",
}
delete headers["x-api-key"]
return fetch(input, {
...init,
headers,
})
},
},
}
},
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 { autoload: false }
const region = process.env["AWS_REGION"] ?? "us-east-1"
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}`
}
return sdk.languageModel(modelID)
},
}
},
google: env("GOOGLE_GENERATIVE_AI_API_KEY"),
openai: env("OPENAI_API_KEY"),
}
const state = App.state("provider", async () => {
const config = await Config.get()
const database: Record<string, Provider.Info> = await ModelsDev.get()
const database = await ModelsDev.get()
const providers: {
[providerID: string]: {
source: Source
info: Provider.Info
info: ModelsDev.Provider
getModel?: (sdk: any, modelID: string) => Promise<any>
options: Record<string, any>
}
} = {}
const models = new Map<string, { info: Model; language: LanguageModel }>()
const models = new Map<
string,
{ info: ModelsDev.Model; language: LanguageModel }
>()
const sdk = new Map<string, SDK>()
log.info("loading")
log.info("init")
function mergeProvider(
id: string,
options: Record<string, any>,
source: Source,
getModel?: (sdk: any, modelID: string) => Promise<any>,
) {
const provider = providers[id]
if (!provider) {
const info = database[id]
if (!info) return
if (info.api) options["baseURL"] = info.api
providers[id] = {
source,
info: database[id] ?? {
id,
name: id,
models: [],
},
info,
options,
}
return
}
provider.options = mergeDeep(provider.options, options)
provider.source = source
provider.getModel = getModel ?? provider.getModel
}
for (const [providerID, fn] of Object.entries(AUTODETECT)) {
const provider = database[providerID]
if (!provider) continue
const result = await fn(provider)
if (!result) continue
mergeProvider(providerID, result.options, result.source)
const configProviders = Object.entries(config.provider ?? {})
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: ModelsDev.Provider = {
id: providerID,
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
models: existing?.models ?? {},
}
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
const existing = parsed.models[modelID]
const parsedModel: ModelsDev.Model = {
id: modelID,
name: model.name ?? existing?.name ?? modelID,
attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false,
tool_call: model.tool_call ?? existing?.tool_call ?? true,
cost: {
...existing?.cost,
...model.cost,
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
},
options: {
...existing?.options,
...model.options,
},
limit: model.limit ??
existing?.limit ?? {
context: 0,
output: 0,
},
}
parsed.models[modelID] = parsedModel
}
database[providerID] = parsed
}
for (const [providerID, info] of Object.entries(await Auth.all())) {
if (info.type === "api") {
mergeProvider(providerID, { apiKey: info.key }, "api")
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")
}
}
for (const [providerID, options] of Object.entries(config.provider ?? {})) {
mergeProvider(providerID, options, "config")
// load apikeys
for (const [providerID, provider] of Object.entries(await Auth.all())) {
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, { apiKey: provider.key }, "api")
}
}
for (const providerID of Object.keys(providers)) {
log.info("loaded", { providerID })
// load custom
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result && (result.autoload || providers[providerID])) {
mergeProvider(
providerID,
result.options ?? {},
"custom",
result.getModel,
)
}
}
// load config
for (const [providerID, provider] of configProviders) {
mergeProvider(providerID, provider.options ?? {}, "config")
}
for (const [providerID, provider] of Object.entries(providers)) {
if (Object.keys(provider.info.models).length === 0) {
delete providers[providerID]
continue
}
log.info("found", { providerID })
}
return {
@@ -188,32 +294,22 @@ export namespace Provider {
return state().then((state) => state.providers)
}
async function getSDK(providerID: string) {
async function getSDK(provider: ModelsDev.Provider) {
return (async () => {
using _ = log.time("getSDK", {
providerID: provider.id,
})
const s = await state()
const existing = s.sdk.get(providerID)
const existing = s.sdk.get(provider.id)
if (existing) return existing
const dir = path.join(
Global.Path.cache,
`node_modules`,
`@ai-sdk`,
providerID,
)
if (!(await Bun.file(path.join(dir, "package.json")).exists())) {
log.info("installing", {
providerID,
})
await BunProc.run(["add", `@ai-sdk/${providerID}@alpha`], {
cwd: Global.Path.cache,
})
}
const mod = await import(path.join(dir))
const pkg = provider.npm ?? provider.id
const mod = await import(await BunProc.install(pkg, "latest"))
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn(s.providers[providerID]?.options)
s.sdk.set(providerID, loaded)
const loaded = fn(s.providers[provider.id]?.options)
s.sdk.set(provider.id, loaded)
return loaded as SDK
})().catch((e) => {
throw new InitError({ providerID: providerID }, { cause: e })
throw new InitError({ providerID: provider.id }, { cause: e })
})
}
@@ -222,7 +318,7 @@ export namespace Provider {
const s = await state()
if (s.models.has(key)) return s.models.get(key)!
log.info("loading", {
log.info("getModel", {
providerID,
modelID,
})
@@ -231,13 +327,12 @@ export namespace Provider {
if (!provider) throw new ModelNotFoundError({ providerID, modelID })
const info = provider.info.models[modelID]
if (!info) throw new ModelNotFoundError({ providerID, modelID })
const sdk = await getSDK(providerID)
const sdk = await getSDK(provider.info)
try {
const language =
// @ts-expect-error
"responses" in sdk ? sdk.responses(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,
@@ -261,7 +356,7 @@ export namespace Provider {
}
const priority = ["gemini-2.5-pro-preview", "codex-mini", "claude-sonnet-4"]
export function sort(models: Model[]) {
export function sort(models: ModelsDev.Model[]) {
return sortBy(
models,
[
@@ -274,7 +369,15 @@ export namespace Provider {
}
export async function defaultModel() {
const [provider] = await list().then((val) => Object.values(val))
const cfg = await Config.get()
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),
),
)
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found")
@@ -284,6 +387,14 @@ export namespace Provider {
}
}
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: providerID,
modelID: rest.join("/"),
}
}
const TOOLS = [
BashTool,
EditTool,
@@ -299,23 +410,66 @@ export namespace Provider {
// MultiEditTool,
WriteTool,
TodoWriteTool,
TaskTool,
// TaskTool,
TodoReadTool,
]
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"),
openai: TOOLS,
anthropic: TOOLS.filter((t) => t.id !== "patch"),
openai: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
})),
azure: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
})),
google: TOOLS,
}
export async function tools(providerID: string) {
/*
const cfg = await Config.get()
if (cfg.tool?.provider?.[providerID])
return cfg.tool.provider[providerID].map(
(id) => TOOLS.find((t) => t.id === id)!,
)
*/
return TOOL_MAPPING[providerID] ?? TOOLS
}
function optionalToNullable(schema: z.ZodTypeAny): z.ZodTypeAny {
if (schema instanceof z.ZodObject) {
const shape = schema.shape
const newShape: Record<string, z.ZodTypeAny> = {}
for (const [key, value] of Object.entries(shape)) {
const zodValue = value as z.ZodTypeAny
if (zodValue instanceof z.ZodOptional) {
newShape[key] = zodValue.unwrap().nullable()
} else {
newShape[key] = optionalToNullable(zodValue)
}
}
return z.object(newShape)
}
if (schema instanceof z.ZodArray) {
return z.array(optionalToNullable(schema.element))
}
if (schema instanceof z.ZodUnion) {
return z.union(
schema.options.map((option: z.ZodTypeAny) =>
optionalToNullable(option),
) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
)
}
return schema
}
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
@@ -0,0 +1,25 @@
import type { LanguageModelV1Prompt } from "ai"
import { unique } from "remeda"
export namespace ProviderTransform {
export function message(
msgs: LanguageModelV1Prompt,
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,
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
}
}
return msgs
}
}
+113 -9
View File
@@ -12,7 +12,10 @@ import { App } from "../app/app"
import { Global } from "../global"
import { mapValues } from "remeda"
import { NamedError } from "../util/error"
import { Fzf } from "../external/fzf"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep"
import { Installation } from "../installation"
import { Config } from "../config/config"
const ERRORS = {
400: {
@@ -55,12 +58,16 @@ export namespace Server {
},
)
})
.use((c, next) => {
.use(async (c, next) => {
log.info("request", {
method: c.req.method,
path: c.req.path,
})
return next()
const start = Date.now()
await next()
log.info("response", {
duration: Date.now() - start,
})
})
.get(
"/openapi",
@@ -134,6 +141,25 @@ export namespace Server {
return c.json(App.info())
},
)
.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",
describeRoute({
@@ -264,6 +290,34 @@ export namespace Server {
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({
@@ -336,6 +390,33 @@ export namespace Server {
return c.json(Session.abort(body.sessionID))
},
)
.post(
"/session_delete",
describeRoute({
description: "Delete a session and all its data",
responses: {
200: {
description: "Successfully deleted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.remove(body.sessionID)
return c.json(true)
},
)
.post(
"/session_summarize",
describeRoute({
@@ -406,7 +487,7 @@ export namespace Server {
"application/json": {
schema: resolver(
z.object({
providers: Provider.Info.array(),
providers: ModelsDev.Provider.array(),
default: z.record(z.string(), z.string()),
}),
),
@@ -421,7 +502,7 @@ export namespace Server {
)
return c.json({
providers: Object.values(providers),
defaults: mapValues(
default: mapValues(
providers,
(item) => Provider.sort(Object.values(item.models))[0].id,
),
@@ -452,10 +533,33 @@ export namespace Server {
async (c) => {
const body = c.req.valid("json")
const app = App.info()
const result = await Fzf.search(app.path.cwd, body.query)
const result = await Ripgrep.files({
cwd: app.path.cwd,
query: body.query,
limit: 10,
})
return c.json(result)
},
)
.post(
"installation_info",
describeRoute({
description: "Get installation info",
responses: {
200: {
description: "Get installation info",
content: {
"application/json": {
schema: resolver(Installation.Info),
},
},
},
},
}),
async (c) => {
return c.json(Installation.info())
},
)
return result
}
@@ -475,10 +579,10 @@ export namespace Server {
return result
}
export function listen() {
export function listen(opts: { port: number; hostname: string }) {
const server = Bun.serve({
port: 0,
hostname: "0.0.0.0",
port: opts.port,
hostname: opts.hostname,
idleTimeout: 0,
fetch: app().fetch,
})
-19
View File
@@ -1,19 +0,0 @@
import { App } from "../app/app"
import { Filesystem } from "../util/filesystem"
export namespace SessionContext {
const FILES = [
"AGENTS.md",
"CLAUDE.md",
"CONTEXT.md", // deprecated
]
export async function find() {
const { cwd, root } = App.info().path
const found = []
for (const item of FILES) {
const matches = await Filesystem.findUp(item, cwd, root)
found.push(...matches.map((x) => Bun.file(x).text()))
}
return Promise.all(found).then((parts) => parts.join("\n\n"))
}
}
+425 -151
View File
@@ -4,15 +4,17 @@ import { Identifier } from "../id/id"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
import {
convertToModelMessages,
generateText,
LoadAPIKeyError,
stepCountIs,
convertToCoreMessages,
streamText,
tool,
type Tool as AITool,
type LanguageModelUsage,
type CoreMessage,
type UIMessage,
type ProviderMetadata,
wrapLanguageModel,
} from "ai"
import { z, ZodSchema } from "zod"
import { Decimal } from "decimal.js"
@@ -27,6 +29,11 @@ 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 { ProviderTransform } from "../provider/transform"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -37,11 +44,11 @@ export namespace Session {
parentID: Identifier.schema("session").optional(),
share: z
.object({
secret: z.string(),
url: z.string(),
})
.optional(),
title: z.string(),
version: z.string(),
time: z.object({
created: z.number(),
updated: z.number(),
@@ -52,6 +59,12 @@ export namespace Session {
})
export type Info = z.output<typeof Info>
export const ShareInfo = z.object({
secret: z.string(),
url: z.string(),
})
export type ShareInfo = z.output<typeof ShareInfo>
export const Event = {
Updated: Bus.event(
"session.updated",
@@ -59,6 +72,12 @@ export namespace Session {
info: Info,
}),
),
Deleted: Bus.event(
"session.deleted",
z.object({
info: Info,
}),
),
Error: Bus.event(
"session.error",
z.object({
@@ -67,19 +86,30 @@ export namespace Session {
),
}
const state = App.state("session", () => {
const sessions = new Map<string, Info>()
const messages = new Map<string, Message.Info[]>()
const state = App.state(
"session",
() => {
const sessions = new Map<string, Info>()
const messages = new Map<string, Message.Info[]>()
const pending = new Map<string, AbortController>()
return {
sessions,
messages,
}
})
return {
sessions,
messages,
pending,
}
},
async (state) => {
for (const [_, controller] of state.pending) {
controller.abort()
}
},
)
export async function create(parentID?: string) {
const result: Info = {
id: Identifier.descending("session"),
version: Installation.VERSION,
parentID,
title:
(parentID ? "Child session - " : "New Session - ") +
@@ -92,7 +122,8 @@ export namespace Session {
log.info("created", result)
state().sessions.set(result.id, result)
await Storage.writeJSON("session/info/" + result.id, result)
if (!result.parentID)
const cfg = await Config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.autoshare))
share(result.id).then((share) => {
update(result.id, (draft) => {
draft.share = share
@@ -114,19 +145,35 @@ export namespace Session {
return read as Info
}
export async function getShare(id: string) {
return Storage.readJSON<ShareInfo>("session/share/" + id)
}
export async function share(id: string) {
const session = await get(id)
if (session.share) return session.share
const share = await Share.create(id)
await update(id, (draft) => {
draft.share = share
draft.share = {
url: share.url,
}
})
await Storage.writeJSON<ShareInfo>("session/share/" + id, share)
await Share.sync("session/info/" + id, session)
for (const msg of await messages(id)) {
await Share.sync("session/message/" + id + "/" + msg.id, msg)
}
return share
}
export async function unshare(id: string) {
await Storage.remove("session/share/" + id)
await update(id, (draft) => {
draft.share = undefined
})
await Share.remove(id)
}
export async function update(id: string, editor: (session: Info) => void) {
const { sessions } = state()
const session = await get(id)
@@ -165,14 +212,47 @@ export namespace Session {
}
}
export async function children(parentID: string) {
const result = [] as Session.Info[]
for await (const item of Storage.list("session/info")) {
const sessionID = path.basename(item, ".json")
const session = await get(sessionID)
if (session.parentID !== parentID) continue
result.push(session)
}
return result
}
export function abort(sessionID: string) {
const controller = pending.get(sessionID)
const controller = state().pending.get(sessionID)
if (!controller) return false
controller.abort()
pending.delete(sessionID)
state().pending.delete(sessionID)
return true
}
export async function remove(sessionID: string, emitEvent = true) {
try {
abort(sessionID)
const session = await get(sessionID)
for (const child of await children(sessionID)) {
await remove(child.id, false)
}
await unshare(sessionID).catch(() => {})
await Storage.remove(`session/info/${sessionID}`).catch(() => {})
await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {})
state().sessions.delete(sessionID)
state().messages.delete(sessionID)
if (emitEvent) {
Bus.publish(Event.Deleted, {
info: session,
})
}
} catch (e) {
log.error(e)
}
}
async function updateMessage(msg: Message.Info) {
await Storage.writeJSON(
"session/message/" + msg.metadata.sessionID + "/" + msg.id,
@@ -201,10 +281,16 @@ export namespace Session {
if (previous?.metadata.assistant) {
const tokens =
previous.metadata.assistant.tokens.input +
previous.metadata.assistant.tokens.cache.read +
previous.metadata.assistant.tokens.cache.write +
previous.metadata.assistant.tokens.output
if (
model.info.limit.context &&
tokens >
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
Math.max(
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9,
0,
)
) {
await summarize({
sessionID: input.sessionID,
@@ -226,32 +312,30 @@ export namespace Session {
const session = await get(input.sessionID)
if (msgs.length === 0 && !session.parentID) {
generateText({
maxOutputTokens: 20,
messages: convertToModelMessages([
maxTokens: input.providerID === "google" ? 1024 : 20,
providerOptions: model.info.options,
messages: [
...SystemPrompt.title(input.providerID).map(
(x): UIMessage => ({
id: Identifier.ascending("message"),
(x): CoreMessage => ({
role: "system",
parts: [
{
type: "text",
text: x,
},
],
content: x,
}),
),
{
role: "user",
parts: input.parts,
},
]),
temperature: 0,
...convertToCoreMessages([
{
role: "user",
content: "",
parts: toParts(input.parts),
},
]),
],
model: model.language,
})
.then((result) => {
return Session.update(input.sessionID, (draft) => {
draft.title = result.text
})
if (result.text)
return Session.update(input.sessionID, (draft) => {
draft.title = result.text
})
})
.catch(() => {})
}
@@ -271,7 +355,7 @@ export namespace Session {
msgs.push(msg)
const system = input.system ?? SystemPrompt.provider(input.providerID)
system.push(...(await SystemPrompt.environment(input.sessionID)))
system.push(...(await SystemPrompt.environment()))
system.push(...(await SystemPrompt.custom()))
const next: Message.Info = {
@@ -290,6 +374,7 @@ export namespace Session {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: input.modelID,
providerID: input.providerID,
@@ -316,6 +401,16 @@ export namespace Session {
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,
@@ -382,11 +477,9 @@ export namespace Session {
let text: Message.TextPart | undefined
const result = streamText({
onStepFinish: async (step) => {
log.info("step finish", {
finishReason: step.finishReason,
})
log.info("step finish", { finishReason: step.finishReason })
const assistant = next.metadata!.assistant!
const usage = getUsage(step.usage, model.info)
const usage = getUsage(model.info, step.usage, step.providerMetadata)
assistant.cost += usage.cost
assistant.tokens = usage.tokens
await updateMessage(next)
@@ -399,18 +492,105 @@ export namespace Session {
}
text = undefined
},
async onChunk(input) {
const value = input.chunk
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,
abortSignal: abort.signal,
maxSteps: 1000,
providerOptions: model.info.options,
messages: [
...system.map(
(x): CoreMessage => ({
role: "system",
content: x,
}),
),
...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
),
],
temperature: model.info.temperature ? 0 : undefined,
tools: model.info.tool_call === false ? undefined : tools,
model: wrapLanguageModel({
model: model.language,
middleware: [
{
async transformParams(args) {
if (args.type === "stream") {
args.params.prompt = ProviderTransform.message(
args.params.prompt,
input.providerID,
input.modelID,
)
}
return args.params
},
},
],
}),
})
try {
for await (const value of result.fullStream) {
l.info("part", {
type: value.type,
})
switch (value.type) {
case "text":
case "step-start":
next.parts.push({
type: "step-start",
})
break
case "text-delta":
if (!text) {
text = value
next.parts.push(value)
text = {
type: "text",
text: value.textDelta,
}
next.parts.push(text)
break
} else text.text += value.text
} else text.text += value.textDelta
break
case "tool-call": {
@@ -449,20 +629,27 @@ export namespace Session {
break
case "tool-call-delta":
break
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, {
@@ -473,86 +660,62 @@ export namespace Session {
}
break
case "finish":
log.info("message finish", {
reason: value.finishReason,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(
model.info,
value.usage,
value.providerMetadata,
)
assistant.cost = usage.cost
await updateMessage(next)
if (value.finishReason === "length")
throw new Message.OutputLengthError({})
break
default:
l.info("unhandled", {
type: value.type,
})
continue
}
await updateMessage(next)
},
async onFinish(input) {
const assistant = next.metadata!.assistant!
const usage = getUsage(input.totalUsage, model.info)
assistant.cost = usage.cost
await updateMessage(next)
},
onError(err) {
log.error("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,
abortSignal: abort.signal,
stopWhen: stepCountIs(1000),
messages: convertToModelMessages([
...system.map(
(x): UIMessage => ({
id: Identifier.ascending("message"),
role: "system",
parts: [
{
type: "text",
text: x,
},
],
}),
),
...msgs,
]),
temperature: model.info.id === "codex-mini-latest" ? undefined : 0,
tools: {
...(await MCP.tools()),
...tools,
},
model: model.language,
})
await result.consumeStream({
onError: (err) => {
log.error("stream error", {
err,
})
},
})
}
} catch (e: any) {
log.error("stream error", {
error: e,
})
switch (true) {
case Message.OutputLengthError.isInstance(e):
next.metadata.error = e
break
case LoadAPIKeyError.isInstance(e):
next.metadata.error = new Provider.AuthError(
{
providerID: input.providerID,
message: e.message,
},
{ cause: e },
).toObject()
break
case e instanceof Error:
next.metadata.error = new NamedError.Unknown(
{ message: e.toString() },
{ cause: e },
).toObject()
break
default:
next.metadata.error = new NamedError.Unknown(
{ message: JSON.stringify(e) },
{ cause: e },
)
}
Bus.publish(Event.Error, {
error: next.metadata.error,
})
}
next.metadata!.time.completed = Date.now()
for (const part of next.parts) {
if (
@@ -606,6 +769,7 @@ export namespace Session {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
time: {
@@ -614,70 +778,126 @@ export namespace Session {
},
}
await updateMessage(next)
const result = await generateText({
let text: Message.TextPart | undefined
const result = streamText({
abortSignal: abort.signal,
model: model.language,
messages: convertToModelMessages([
messages: [
...system.map(
(x): UIMessage => ({
id: Identifier.ascending("message"),
(x): CoreMessage => ({
role: "system",
parts: [
{
type: "text",
text: x,
},
],
content: x,
}),
),
...filtered,
...convertToCoreMessages(filtered.map(toUIMessage)),
{
role: "user",
parts: [
content: [
{
type: "text",
text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.",
},
],
},
]),
],
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
await updateMessage(next)
if (text) {
Bus.publish(Message.Event.PartUpdated, {
part: text,
messageID: next.id,
sessionID: next.metadata.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()
await updateMessage(next)
},
})
next.parts.push({
type: "text",
text: result.text,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(result.usage, model.info)
assistant.cost = usage.cost
assistant.tokens = usage.tokens
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
}
}
}
const pending = new Map<string, AbortController>()
function lock(sessionID: string) {
log.info("locking", { sessionID })
if (pending.has(sessionID)) throw new BusyError(sessionID)
if (state().pending.has(sessionID)) throw new BusyError(sessionID)
const controller = new AbortController()
pending.set(sessionID, controller)
state().pending.set(sessionID, controller)
return {
signal: controller.signal,
[Symbol.dispose]() {
log.info("unlocking", { sessionID })
pending.delete(sessionID)
state().pending.delete(sessionID)
Config.get().then((cfg) => {
if (cfg.experimental?.hook?.session_completed) {
for (const item of cfg.experimental.hook.session_completed) {
Bun.spawn({
cmd: item.command,
cwd: App.info().path.cwd,
env: item.environment,
})
}
}
})
},
}
}
function getUsage(usage: LanguageModelUsage, model: Provider.Model) {
function getUsage(
model: ModelsDev.Model,
usage: LanguageModelUsage,
metadata?: ProviderMetadata,
) {
const tokens = {
input: usage.inputTokens ?? 0,
output: usage.outputTokens ?? 0,
reasoning: usage.reasoningTokens ?? 0,
input: usage.promptTokens ?? 0,
output: usage.completionTokens ?? 0,
reasoning: 0,
cache: {
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
0) as number,
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
0) as number,
},
}
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),
)
.toNumber(),
tokens,
}
@@ -709,3 +929,57 @@ 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
}
+52 -40
View File
@@ -4,6 +4,11 @@ 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"),
@@ -135,49 +140,56 @@ export namespace Message {
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(Part),
metadata: z.object({
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [
Provider.AuthError.Schema,
NamedError.Unknown.Schema,
])
.optional(),
sessionID: z.string(),
tool: z.record(
z.string(),
z
metadata: z
.object({
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [
Provider.AuthError.Schema,
NamedError.Unknown.Schema,
OutputLengthError.Schema,
])
.optional(),
sessionID: z.string(),
tool: z.record(
z.string(),
z
.object({
title: z.string(),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.catchall(z.any()),
),
assistant: z
.object({
title: z.string(),
time: z.object({
start: z.number(),
end: z.number(),
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(),
}),
}),
})
.catchall(z.any()),
),
assistant: z
.object({
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(),
}),
})
.optional(),
}),
.optional(),
})
.openapi({ ref: "Message.Metadata" }),
})
.openapi({
ref: "Message.Info",
@@ -1,14 +1,14 @@
You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
- /help: Get help with using OpenCode
- /help: Get help with using opencode
- To give feedback, users should report the issue at https://github.com/sst/opencode/issues
When the user directly asks about OpenCode (eg 'can OpenCode do...', 'does OpenCode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from OpenCode docs at https://opencode.ai
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
+11 -7
View File
@@ -1,7 +1,11 @@
you will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 50 characters long
- the title should be a summary of the user's message
- it should be one line long
- do not use quotes or colons
- the entire text you return will be used as the title
- never return anything that is more than one sentence (one line) long
Generate a short title based on the first message a user begins a conversation with. CRITICAL: Your response must be EXACTLY one line with NO line breaks, newlines, or multiple sentences.
Requirements:
- Maximum 50 characters
- Single line only - NO newlines or line breaks
- Summary of the user's message
- No quotes, colons, or special formatting
- Do not include explanatory text like "summary:" or similar
- Your entire response becomes the title
IMPORTANT: Return only the title text on a single line. Do not add any explanations, formatting, or additional text.
+68 -6
View File
@@ -1,6 +1,9 @@
import { App } from "../app/app"
import { ListTool } from "../tool/ls"
import { Ripgrep } from "../external/ripgrep"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import path from "path"
import os from "os"
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
@@ -22,8 +25,57 @@ export namespace SystemPrompt {
return result
}
export async function environment(sessionID: string) {
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:`,
@@ -33,9 +85,9 @@ export namespace SystemPrompt {
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
`<project>`,
` ${app.git ? await ListTool.execute({ path: app.path.cwd, ignore: [] }, { sessionID: sessionID, messageID: "", abort: AbortSignal.any([]) }).then((x) => x.output) : ""}`,
`</project>`,
// `<project>`,
// ` ${app.git ? await tree() : ""}`,
// `</project>`,
].join("\n"),
]
}
@@ -52,7 +104,17 @@ export namespace SystemPrompt {
const matches = await Filesystem.findUp(item, cwd, root)
found.push(...matches.map((x) => Bun.file(x).text()))
}
return Promise.all(found)
found.push(
Bun.file(path.join(Global.Path.config, "AGENTS.md"))
.text()
.catch(() => ""),
)
found.push(
Bun.file(path.join(os.homedir(), ".claude", "CLAUDE.md"))
.text()
.catch(() => ""),
)
return Promise.all(found).then((result) => result.filter(Boolean))
}
export function summarize(providerID: string) {
+17 -5
View File
@@ -1,5 +1,6 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { Installation } from "../installation"
import { Session } from "../session"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
@@ -19,10 +20,11 @@ export namespace Share {
export async function sync(key: string, content: any) {
const [root, ...splits] = key.split("/")
if (root !== "session") return
const [, sessionID] = splits
const session = await Session.get(sessionID)
if (!session.share) return
const { secret } = session.share
const [sub, sessionID] = splits
if (sub === "share") return
const share = await Session.getShare(sessionID).catch(() => {})
if (!share) return
const { secret } = share
pending.set(key, content)
queue = queue
.then(async () => {
@@ -55,7 +57,10 @@ export namespace Share {
}
export const URL =
process.env["OPENCODE_API"] ?? "https://api.dev.opencode.ai"
process.env["OPENCODE_API"] ??
(Installation.isSnapshot() || Installation.isDev()
? "https://api.dev.opencode.ai"
: "https://api.opencode.ai")
export async function create(sessionID: string) {
return fetch(`${URL}/share_create`, {
@@ -65,4 +70,11 @@ export namespace Share {
.then((x) => x.json())
.then((x) => x as { url: string; secret: string })
}
export async function remove(id: string) {
return fetch(`${URL}/share_delete`, {
method: "POST",
body: JSON.stringify({ id }),
}).then((x) => x.json())
}
}
+9 -1
View File
@@ -24,7 +24,15 @@ export namespace Storage {
}
})
const locks = new Map<string, Promise<void>>()
export async function remove(key: string) {
const target = path.join(state().dir, key + ".json")
await fs.unlink(target).catch(() => {})
}
export async function removeDir(key: string) {
const target = path.join(state().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>
+11 -3
View File
@@ -26,7 +26,7 @@ const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
export const BashTool = Tool.define({
id: "opencode.bash",
id: "bash",
description: DESCRIPTION,
parameters: z.object({
command: z.string().describe("The command to execute"),
@@ -35,7 +35,7 @@ export const BashTool = Tool.define({
.min(0)
.max(MAX_TIMEOUT)
.describe("Optional timeout in milliseconds")
.nullable(),
.optional(),
description: z
.string()
.describe(
@@ -63,10 +63,18 @@ export const BashTool = Tool.define({
metadata: {
stderr,
stdout,
exit: process.exitCode,
description: params.description,
title: params.command,
},
output: stdout.replaceAll(/\x1b\[[0-9;]*m/g, ""),
output: [
`<stdout>`,
stdout ?? "",
`</stdout>`,
`<stderr>`,
stderr ?? "",
`</stderr>`,
].join("\n"),
}
},
})
+6 -6
View File
@@ -22,7 +22,7 @@ Usage notes:
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
- VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and LS to read files.
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all OpenCode users have pre-installed.
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all opencode users have pre-installed.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
<good-example>
@@ -60,9 +60,9 @@ When the user asks you to create a new git commit, follow these steps carefully:
3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel:
- Add relevant untracked files to the staging area.
- Create the commit with a message ending with:
🤖 Generated with [OpenCode](https://opencode.ai)
🤖 Generated with [opencode](https://opencode.ai)
Co-Authored-By: OpenCode <noreply@opencode.ai>
Co-Authored-By: opencode <noreply@opencode.ai>
- Run git status to make sure the commit succeeded.
4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
@@ -81,9 +81,9 @@ Important notes:
git commit -m "$(cat <<'EOF'
Commit message here.
🤖 Generated with [OpenCode](https://opencode.ai)
🤖 Generated with [opencode](https://opencode.ai)
Co-Authored-By: OpenCode <noreply@opencode.ai>
Co-Authored-By: opencode <noreply@opencode.ai>
EOF
)"
</example>
@@ -128,7 +128,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Test plan
[Checklist of TODOs for testing the pull request...]
🤖 Generated with [OpenCode](https://opencode.ai)
🤖 Generated with [opencode](https://opencode.ai)
EOF
)"
</example>
+421 -29
View File
@@ -1,3 +1,7 @@
// 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"
@@ -7,9 +11,10 @@ import { createTwoFilesPatch } from "diff"
import { Permission } from "../permission"
import DESCRIPTION from "./edit.txt"
import { App } from "../app/app"
import { Format } from "../format"
export const EditTool = Tool.define({
id: "opencode.edit",
id: "edit",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
@@ -21,21 +26,25 @@ export const EditTool = Tool.define({
),
replaceAll: z
.boolean()
.nullable()
.describe("Replace all occurences of old_string (default false)"),
.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)
await Permission.ask({
id: "opencode.edit",
id: "edit",
sessionID: ctx.sessionID,
title: "Edit this file: " + filepath,
metadata: {
@@ -51,43 +60,31 @@ export const EditTool = Tool.define({
if (params.oldString === "") {
contentNew = params.newString
await Bun.write(filepath, params.newString)
await Format.run(filepath)
return
}
const file = Bun.file(filepath)
if (!(await file.exists())) throw new Error(`File ${filepath} not found`)
const stats = await file.stat()
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 FileTimes.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 Format.run(filepath)
})()
const diff = createTwoFilesPatch(filepath, filepath, contentOld, contentNew)
const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
)
FileTimes.read(ctx.sessionID, filepath)
@@ -113,3 +110,398 @@ export const EditTool = Tool.define({
}
},
})
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(
(line) =>
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++"),
)
if (contentLines.length === 0) return diff
let min = Infinity
for (const line of contentLines) {
const content = line.slice(1)
if (content.trim().length > 0) {
const match = content.match(/^(\s*)/)
if (match) min = Math.min(min, match[1].length)
}
}
if (min === Infinity || min === 0) return diff
const trimmedLines = lines.map((line) => {
if (
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++")
) {
const prefix = line[0]
const content = line.slice(1)
return prefix + content.slice(min)
}
return line
})
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")
}
+7 -4
View File
@@ -3,15 +3,16 @@ import path from "path"
import { Tool } from "./tool"
import { App } from "../app/app"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../external/ripgrep"
export const GlobTool = Tool.define({
id: "opencode.glob",
id: "glob",
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"),
path: z
.string()
.nullable()
.optional()
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
@@ -24,10 +25,12 @@ export const GlobTool = Tool.define({
: path.resolve(app.path.cwd, search)
const limit = 100
const glob = new Bun.Glob(params.pattern)
const files = []
let truncated = false
for await (const file of glob.scan({ cwd: search })) {
for (const file of await Ripgrep.files({
cwd: search,
glob: params.pattern,
})) {
if (files.length >= limit) {
truncated = true
break
+3 -3
View File
@@ -6,7 +6,7 @@ import { Ripgrep } from "../external/ripgrep"
import DESCRIPTION from "./grep.txt"
export const GrepTool = Tool.define({
id: "opencode.grep",
id: "grep",
description: DESCRIPTION,
parameters: z.object({
pattern: z
@@ -14,13 +14,13 @@ export const GrepTool = Tool.define({
.describe("The regex pattern to search for in file contents"),
path: z
.string()
.nullable()
.optional()
.describe(
"The directory to search in. Defaults to the current working directory.",
),
include: z
.string()
.nullable()
.optional()
.describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
),
+10 -9
View File
@@ -4,7 +4,7 @@ import { App } from "../app/app"
import * as path from "path"
import DESCRIPTION from "./ls.txt"
const IGNORE_PATTERNS = [
export const IGNORE_PATTERNS = [
"node_modules/",
"__pycache__/",
".git/",
@@ -18,8 +18,10 @@ const IGNORE_PATTERNS = [
".vscode/",
]
const LIMIT = 100
export const ListTool = Tool.define({
id: "opencode.list",
id: "list",
description: DESCRIPTION,
parameters: z.object({
path: z
@@ -27,11 +29,11 @@ export const ListTool = Tool.define({
.describe(
"The absolute path to the directory to list (must be absolute, not relative)",
)
.nullable(),
.optional(),
ignore: z
.array(z.string())
.describe("List of glob patterns to ignore")
.nullable(),
.optional(),
}),
async execute(params) {
const app = App.info()
@@ -40,13 +42,12 @@ export const ListTool = Tool.define({
const glob = new Bun.Glob("**/*")
const files = []
for await (const file of glob.scan({ cwd: searchPath })) {
if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p)))
continue
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
files.push(file)
if (files.length >= 1000) break
if (files.length >= LIMIT) break
}
// Build directory structure
@@ -100,7 +101,7 @@ export const ListTool = Tool.define({
return {
metadata: {
count: files.length,
truncated: files.length >= 1000,
truncated: files.length >= LIMIT,
title: path.relative(app.path.root, searchPath),
},
output,
@@ -6,7 +6,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./lsp-diagnostics.txt"
export const LspDiagnosticTool = Tool.define({
id: "opencode.lsp_diagnostics",
id: "lsp_diagnostics",
description: DESCRIPTION,
parameters: z.object({
path: z.string().describe("The path to the file to get diagnostics."),
+1 -1
View File
@@ -6,7 +6,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./lsp-hover.txt"
export const LspHoverTool = Tool.define({
id: "opencode.lsp_hover",
id: "lsp_hover",
description: DESCRIPTION,
parameters: z.object({
file: z.string().describe("The path to the file to get diagnostics."),
+1 -1
View File
@@ -6,7 +6,7 @@ import path from "path"
import { App } from "../app/app"
export const MultiEditTool = Tool.define({
id: "opencode.multiedit",
id: "multiedit",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
+1 -1
View File
@@ -10,7 +10,7 @@ To make multiple file edits, provide the following:
2. edits: An array of edit operations to perform, where each edit contains:
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
- new_string: The edited text to replace the old_string
- replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.
- replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
IMPORTANT:
- All edits are applied in sequence, in the order they are provided
+1 -2
View File
@@ -4,7 +4,6 @@ import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import DESCRIPTION from "./patch.txt"
import { App } from "../app/app"
const PatchParams = z.object({
patchText: z
@@ -233,7 +232,7 @@ async function applyCommit(
}
export const PatchTool = Tool.define({
id: "opencode.patch",
id: "patch",
description: DESCRIPTION,
parameters: PatchParams,
execute: async (params, ctx) => {
+3 -3
View File
@@ -12,18 +12,18 @@ const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
export const ReadTool = Tool.define({
id: "opencode.read",
id: "read",
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)")
.nullable(),
.optional(),
limit: z
.number()
.describe("The number of lines to read (defaults to 2000)")
.nullable(),
.optional(),
}),
async execute(params, ctx) {
let filePath = params.filePath
+30 -2
View File
@@ -2,9 +2,11 @@ import { Tool } from "./tool"
import DESCRIPTION from "./task.txt"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
import { Message } from "../session/message"
export const TaskTool = Tool.define({
id: "opencode.task",
id: "task",
description: DESCRIPTION,
parameters: z.object({
description: z
@@ -17,6 +19,31 @@ export const TaskTool = Tool.define({
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
const metadata = msg.metadata.assistant!
function summary(input: Message.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],
})
}
}
return result
}
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
if (evt.properties.info.metadata.sessionID !== session.id) return
ctx.metadata({
title: params.description,
summary: summary(evt.properties.info),
})
})
ctx.abort.addEventListener("abort", () => {
Session.abort(session.id)
})
const result = await Session.chat({
sessionID: session.id,
modelID: metadata.modelID,
@@ -28,10 +55,11 @@ export const TaskTool = Tool.define({
},
],
})
unsub()
return {
metadata: {
title: params.description,
summary: summary(result),
},
output: result.parts.findLast((x) => x.type === "text")!.text,
}
+2 -2
View File
@@ -23,7 +23,7 @@ const state = App.state("todo-tool", () => {
})
export const TodoWriteTool = Tool.define({
id: "opencode.todowrite",
id: "todowrite",
description: DESCRIPTION_WRITE,
parameters: z.object({
todos: z.array(TodoInfo).describe("The updated todo list"),
@@ -42,7 +42,7 @@ export const TodoWriteTool = Tool.define({
})
export const TodoReadTool = Tool.define({
id: "opencode.todoread",
id: "todoread",
description: "Use this tool to read your todo list",
parameters: z.object({}),
async execute(_params, opts) {
+2 -1
View File
@@ -5,10 +5,11 @@ export namespace Tool {
title: string
[key: string]: any
}
export type Context = {
export type Context<M extends Metadata = Metadata> = {
sessionID: string
messageID: string
abort: AbortSignal
metadata(meta: M): void
}
export interface Info<
Parameters extends StandardSchemaV1 = StandardSchemaV1,
+42 -7
View File
@@ -8,7 +8,7 @@ const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const WebFetchTool = Tool.define({
id: "opencode.webfetch",
id: "webfetch",
description: DESCRIPTION,
parameters: z.object({
url: z.string().describe("The URL to fetch content from"),
@@ -22,7 +22,7 @@ export const WebFetchTool = Tool.define({
.min(0)
.max(MAX_TIMEOUT / 1000)
.describe("Optional timeout in seconds (max 120)")
.nullable(),
.optional(),
}),
async execute(params, ctx) {
// Validate URL
@@ -76,7 +76,7 @@ export const WebFetchTool = Tool.define({
switch (params.format) {
case "text":
if (contentType.includes("text/html")) {
const text = extractTextFromHTML(content)
const text = await extractTextFromHTML(content)
return {
output: text,
metadata: {
@@ -127,10 +127,45 @@ export const WebFetchTool = Tool.define({
},
})
function extractTextFromHTML(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html")
const text = doc.body.textContent || doc.body.innerText || ""
return text.replace(/\s+/g, " ").trim()
async function extractTextFromHTML(html: string) {
let text = ""
let skipContent = false
const rewriter = new HTMLRewriter()
.on("script, style, noscript, iframe, object, embed", {
element() {
skipContent = true
},
text() {
// Skip text content inside these elements
},
})
.on("*", {
element(element) {
// Reset skip flag when entering other elements
if (
![
"script",
"style",
"noscript",
"iframe",
"object",
"embed",
].includes(element.tagName)
) {
skipContent = false
}
},
text(input) {
if (!skipContent) {
text += input.text
}
},
})
.transform(new Response(html))
await rewriter.text()
return text.trim()
}
function convertHTMLToMarkdown(html: string): string {
+1 -1
View File
@@ -1,5 +1,5 @@
- Allows OpenCode to search the web and use the results to inform responses
- Allows opencode to search the web and use the results to inform responses
- Provides up-to-date information for current events and recent data
- Returns search result information formatted as search result blocks
- Use this tool for accessing information beyond Claude's knowledge cutoff
+4 -2
View File
@@ -6,9 +6,10 @@ import { LSP } from "../lsp"
import { Permission } from "../permission"
import DESCRIPTION from "./write.txt"
import { App } from "../app/app"
import { Format } from "../format"
export const WriteTool = Tool.define({
id: "opencode.write",
id: "write",
description: DESCRIPTION,
parameters: z.object({
filePath: z
@@ -29,7 +30,7 @@ export const WriteTool = Tool.define({
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
await Permission.ask({
id: "opencode.write",
id: "write",
sessionID: ctx.sessionID,
title: exists
? "Overwrite this file: " + filepath
@@ -42,6 +43,7 @@ export const WriteTool = Tool.define({
})
await Bun.write(filepath, params.content)
await Format.run(filepath)
FileTimes.read(ctx.sessionID, filepath)
let output = ""
+12 -12
View File
@@ -1,7 +1,7 @@
import { z, type ZodSchema } from "zod"
import { Log } from "./log"
// import { Log } from "./log"
const log = Log.create()
// const log = Log.create()
export abstract class NamedError extends Error {
abstract schema(): ZodSchema
@@ -11,15 +11,16 @@ export abstract class NamedError extends Error {
name: Name,
data: Data,
) {
const schema = z
.object({
name: z.literal(name),
data,
})
.openapi({
ref: name,
})
const result = class extends NamedError {
public static readonly Schema = z
.object({
name: z.literal(name),
data: data,
})
.openapi({
ref: name,
})
public static readonly Schema = schema
public readonly name = name as Name
@@ -29,7 +30,6 @@ export abstract class NamedError extends Error {
) {
super(name, options)
this.name = name
log.error(name, this.data)
}
static isInstance(input: any): input is InstanceType<typeof result> {
@@ -37,7 +37,7 @@ export abstract class NamedError extends Error {
}
schema() {
return result.Schema
return schema
}
toObject() {
+29 -7
View File
@@ -2,7 +2,7 @@ import path from "path"
import fs from "fs/promises"
import { Global } from "../global"
export namespace Log {
export const Default = create()
export const Default = create({ service: "default" })
export interface Options {
print: boolean
@@ -19,7 +19,7 @@ export namespace Log {
await fs.mkdir(dir, { recursive: true })
cleanup(dir)
if (options.print) return
logpath = path.join(dir, new Date().toISOString().split(".")[0] + ".log")
logpath = path.join(dir, new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log")
const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer()
@@ -45,6 +45,7 @@ export namespace Log {
)
}
let last = Date.now()
export function create(tags?: Record<string, any>) {
tags = tags || {}
@@ -56,20 +57,24 @@ export namespace Log {
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}=${value}`)
.join(" ")
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return (
[new Date().toISOString(), prefix, message].filter(Boolean).join(" ") +
"\n"
[next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message]
.filter(Boolean)
.join(" ") + "\n"
)
}
const result = {
info(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra))
process.stderr.write("INFO " + build(message, extra))
},
error(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra))
process.stderr.write("ERROR " + build(message, extra))
},
warn(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra))
process.stderr.write("WARN " + build(message, extra))
},
tag(key: string, value: string) {
if (tags) tags[key] = value
@@ -78,6 +83,23 @@ export namespace Log {
clone() {
return Log.create({ ...tags })
},
time(message: string, extra?: Record<string, any>) {
const now = Date.now()
result.info(message, { status: "started", ...extra })
function stop() {
result.info(message, {
status: "completed",
duration: Date.now() - now,
...extra,
})
}
return {
stop,
[Symbol.dispose]() {
stop()
},
}
},
}
return result
+413
View File
@@ -0,0 +1,413 @@
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)
}
})
})
+25 -16
View File
@@ -3,25 +3,37 @@ import { App } from "../../src/app/app"
import { GlobTool } from "../../src/tool/glob"
import { ListTool } from "../../src/tool/ls"
const ctx = {
sessionID: "test",
messageID: "",
abort: AbortSignal.any([]),
metadata: () => {},
}
describe("tool.glob", () => {
test("truncate", async () => {
await App.provide({ cwd: process.cwd(), version: "test" }, async () => {
await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute(
{ pattern: "./node_modules/**/*" },
{ sessionID: "test" },
{
pattern: "../../node_modules/**/*",
path: undefined,
},
ctx,
)
expect(result.metadata.truncated).toBe(true)
})
})
test("basic", async () => {
await App.provide({ cwd: process.cwd(), version: "test" }, async () => {
await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute(
{ pattern: "*.json" },
{ sessionID: "test" },
{
pattern: "*.json",
path: undefined,
},
ctx,
)
expect(result.metadata).toMatchObject({
truncated: false,
count: 2,
count: 3,
})
})
})
@@ -29,15 +41,12 @@ describe("tool.glob", () => {
describe("tool.ls", () => {
test("basic", async () => {
const result = await App.provide(
{ cwd: process.cwd(), version: "test" },
async () => {
return await ListTool.execute(
{ path: "./example" },
{ sessionID: "test" },
)
},
)
const result = await App.provide({ cwd: process.cwd() }, async () => {
return await ListTool.execute(
{ path: "./example", ignore: [".git"] },
ctx,
)
})
expect(result.output).toMatchSnapshot()
})
})
+1
View File
@@ -0,0 +1 @@
opencode-test
+26
View File
@@ -0,0 +1,26 @@
# 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)
- **Generate client**: `go generate ./pkg/client/` (after server endpoint changes)
- **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
-219
View File
@@ -1,219 +0,0 @@
time=2025-05-30T22:01:45.386-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-30T22:01:45.391-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="TUI exited" result="{width:98 height:57 currentPage:chat previousPage: pages:map[chat:0xc00013b450] loadedPages:map[chat:true] status:{app:0xc0002e05b0 queue:[] width:98 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e05b0 showPermissions:false permissions:0xc000159408 showHelp:false help:0xc0006822d0 showQuit:true quit:0xc00024b479 showSessionDialog:false sessionDialog:0xc0001f0240 showCommandDialog:false commandDialog:0xc0003cbba0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0001f45a0 showInitDialog:false initDialog:{width:98 height:57 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0001f0480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001f04c0}"
time=2025-05-30T22:13:24.046-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-30T22:13:24.051-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="TUI exited" result="{width:199 height:57 currentPage:chat previousPage: pages:map[chat:0xc00025f950] loadedPages:map[chat:true] status:{app:0xc0000ca230 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0000ca230 showPermissions:false permissions:0xc00029f908 showHelp:false help:0xc00045d9b0 showQuit:true quit:0xc0005a0be9 showSessionDialog:false sessionDialog:0xc00012e3c0 showCommandDialog:false commandDialog:0xc0004379e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0002f2e60 showInitDialog:false initDialog:{width:199 height:57 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00012e600 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00012e640}"
time=2025-05-31T16:00:29.137-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T16:00:29.141-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T16:00:36.530-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="TUI exited" result="{width:106 height:54 currentPage:chat previousPage: pages:map[chat:0xc000157450] loadedPages:map[chat:true] status:{app:0xc00020c5b0 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc00020c5b0 showPermissions:false permissions:0xc000175408 showHelp:false help:0xc00070c270 showQuit:true quit:0xc000299979 showSessionDialog:false sessionDialog:0xc0001f02c0 showCommandDialog:false commandDialog:0xc0003cbba0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc00021a5a0 showInitDialog:false initDialog:{width:106 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0001f0500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001f0540}"
time=2025-05-31T16:06:20.089-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T16:06:20.094-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T16:06:20.095-04:00 level=ERROR msg="Failed to subscribe to events" error="Get \"http://localhost:16713/event\": dial tcp [::1]:16713: connect: connection refused"
time=2025-05-31T17:54:04.009-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T17:54:04.014-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc0002332c0] loadedPages:map[chat:true] status:{app:0xc0002b1810 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002b1810 showPermissions:false permissions:0xc000267408 showHelp:false help:0xc00048dbc0 showQuit:true quit:0xc0004a2719 showSessionDialog:false sessionDialog:0xc000319ec0 showCommandDialog:false commandDialog:0xc000387980 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0000c6960 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc0000ac480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac4c0}"
time=2025-05-31T17:54:17.103-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T17:54:17.108-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T17:54:18.391-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc00042a960] loadedPages:map[chat:true] status:{app:0xc000163ce0 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000163ce0 showPermissions:false permissions:0xc0001df408 showHelp:false help:0xc0005198f0 showQuit:true quit:0xc0003a5ef9 showSessionDialog:false sessionDialog:0xc000323840 showCommandDialog:false commandDialog:0xc00043b0e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0004028c0 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc000323a80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323ac0}"
time=2025-05-31T17:59:54.360-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T17:59:54.364-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T17:59:55.814-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc0002787d0] loadedPages:map[chat:true] status:{app:0xc0003fed90 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0003fed90 showPermissions:false permissions:0xc0002b1908 showHelp:false help:0xc000126150 showQuit:true quit:0xc00011d439 showSessionDialog:false sessionDialog:0xc00025e380 showCommandDialog:false commandDialog:0xc00047fc00 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0002f6d20 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0001b2c88 showThemeDialog:false themeDialog:0xc00025e5c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00025e600}"
time=2025-05-31T17:59:56.746-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T17:59:56.750-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="TUI exited" result="{width:211 height:54 currentPage:chat previousPage: pages:map[chat:0xc00053b090] loadedPages:map[chat:true] status:{app:0xc000300cb0 queue:[] width:211 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000300cb0 showPermissions:false permissions:0xc0002c5408 showHelp:false help:0xc000682f90 showQuit:true quit:0xc0006134d9 showSessionDialog:false sessionDialog:0xc00031f980 showCommandDialog:false commandDialog:0xc0003d9520 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc000395220 showInitDialog:false initDialog:{width:211 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00031fbc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00031fc00}"
time=2025-05-31T18:35:42.289-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T18:35:42.294-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc00012f0e0] loadedPages:map[chat:true] status:{app:0xc0002aa070 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002aa070 showPermissions:false permissions:0xc000267408 showHelp:false help:0xc00041b8f0 showQuit:true quit:0xc000345ee9 showSessionDialog:false sessionDialog:0xc00032ba40 showCommandDialog:false commandDialog:0xc00043b300 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc000426f00 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc00032bc80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00032bcc0}"
time=2025-05-31T18:36:56.011-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T18:36:56.015-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T18:37:44.063-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="TUI exited" result="{width:211 height:54 currentPage:chat previousPage: pages:map[chat:0xc000420280] loadedPages:map[chat:true] status:{app:0xc0002d8000 queue:[] width:211 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002d8000 showPermissions:false permissions:0xc000271408 showHelp:false help:0xc00048da70 showQuit:true quit:0xc000390809 showSessionDialog:false sessionDialog:0xc000323b80 showCommandDialog:false commandDialog:0xc0003e5920 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc00025f9a0 showInitDialog:false initDialog:{width:211 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc000323dc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323e00}"
time=2025-05-31T20:32:32.443-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T20:32:32.448-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc00032c960] loadedPages:map[chat:true] status:{app:0xc000279420 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000279420 showPermissions:false permissions:0xc0001fb408 showHelp:false help:0xc000154150 showQuit:true quit:0xc000528849 showSessionDialog:false sessionDialog:0xc000309e40 showCommandDialog:false commandDialog:0xc0003a3800 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002e7cc0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac400 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac440}"
time=2025-06-01T14:37:36.423-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T14:37:36.427-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc00035b9f0] loadedPages:map[chat:true] status:{app:0xc000226d90 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000226d90 showPermissions:false permissions:0xc00027f908 showHelp:false help:0xc0005139e0 showQuit:true quit:0xc000510d49 showSessionDialog:false sessionDialog:0xc0001e84c0 showCommandDialog:false commandDialog:0xc00051a160 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002675e0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc0001e8700 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001e8740}"
time=2025-06-01T14:38:50.886-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T14:38:50.891-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0005ac8c0] loadedPages:map[chat:true] status:{app:0xc0002796c0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002796c0 showPermissions:false permissions:0xc00028b408 showHelp:false help:0xc000490d80 showQuit:true quit:0xc000582589 showSessionDialog:false sessionDialog:0xc0003359c0 showCommandDialog:false commandDialog:0xc00042d480 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc000389360 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000335c00 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000335c80}"
time=2025-06-01T14:39:49.852-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T14:39:49.856-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000616f00] loadedPages:map[chat:true] status:{app:0xc000333490 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000333490 showPermissions:false permissions:0xc0004faa08 showHelp:false help:0xc000471140 showQuit:true quit:0xc000459299 showSessionDialog:false sessionDialog:0xc000352500 showCommandDialog:false commandDialog:0xc00041ed80 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc000515a40 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc000352740 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000352780}"
time=2025-06-01T14:40:21.954-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T14:40:21.958-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0002cc280] loadedPages:map[chat:true] status:{app:0xc0002e64d0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e64d0 showPermissions:false permissions:0xc00026f408 showHelp:false help:0xc00051c1b0 showQuit:true quit:0xc00051a819 showSessionDialog:false sessionDialog:0xc00030fec0 showCommandDialog:false commandDialog:0xc00042d760 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002ce1e0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac4c0}"
time=2025-06-01T14:58:27.272-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T14:58:27.276-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000316280] loadedPages:map[chat:true] status:{app:0xc0002b5810 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002b5810 showPermissions:false permissions:0xc000269408 showHelp:false help:0xc000490e10 showQuit:true quit:0xc00047a929 showSessionDialog:false sessionDialog:0xc0000adb40 showCommandDialog:false commandDialog:0xc0003e59c0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc00024fd60 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000add80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000addc0}"
time=2025-06-01T15:02:54.453-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T15:02:54.458-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000392ff0] loadedPages:map[chat:true] status:{app:0xc0001ecc40 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0001ecc40 showPermissions:false permissions:0xc000205408 showHelp:false help:0xc00051c0c0 showQuit:true quit:0xc0003b3f49 showSessionDialog:false sessionDialog:0xc000319980 showCommandDialog:false commandDialog:0xc00042d220 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0005c52c0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000319bc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000319c00}"
time=2025-06-01T15:02:57.053-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T15:02:57.057-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0004411d0] loadedPages:map[chat:true] status:{app:0xc00023ee70 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc00023ee70 showPermissions:false permissions:0xc000177408 showHelp:false help:0xc000520030 showQuit:true quit:0xc000314929 showSessionDialog:false sessionDialog:0xc000319d00 showCommandDialog:false commandDialog:0xc0003e5860 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0002c9a40 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000319f40 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac000}"
time=2025-06-01T15:15:13.582-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T15:15:13.587-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T15:15:19.009-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0001490e0] loadedPages:map[chat:true] status:{app:0xc0001efb90 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0001efb90 showPermissions:false permissions:0xc000167408 showHelp:false help:0xc00052c1b0 showQuit:true quit:0xc000254629 showSessionDialog:false sessionDialog:0xc00030fe80 showCommandDialog:false commandDialog:0xc0003a3420 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0000c6640 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac440 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac480}"
time=2025-06-01T15:15:20.678-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T15:15:20.683-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T15:15:23.252-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0002c47d0] loadedPages:map[chat:true] status:{app:0xc0003363f0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0003363f0 showPermissions:false permissions:0xc0002f3408 showHelp:false help:0xc0007055f0 showQuit:true quit:0xc00041c9b9 showSessionDialog:false sessionDialog:0xc00033bd00 showCommandDialog:false commandDialog:0xc000437700 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0003d9c20 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00033bf40 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00034a140}"
time=2025-06-02T11:40:21.643-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T11:40:21.648-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc00020d180] loadedPages:map[chat:true] status:{app:0xc0002a8230 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002a8230 showPermissions:false permissions:0xc000239408 showHelp:false help:0xc00051c1e0 showQuit:true quit:0xc000598909 showSessionDialog:false sessionDialog:0xc000309f00 showCommandDialog:false commandDialog:0xc0003a3660 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0000c4a00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa500}"
time=2025-06-02T11:40:55.224-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T11:40:55.228-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc0001467d0] loadedPages:map[chat:true] status:{app:0xc0004feee0 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0004feee0 showPermissions:false permissions:0xc000167408 showHelp:false help:0xc00059cd50 showQuit:true quit:0xc00038aaa9 showSessionDialog:false sessionDialog:0xc00030ff00 showCommandDialog:false commandDialog:0xc0003e5aa0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00029be00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa500}"
time=2025-06-02T11:41:05.131-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T11:41:05.136-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc0002527d0] loadedPages:map[chat:true] status:{app:0xc0002e0d90 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e0d90 showPermissions:false permissions:0xc00027b408 showHelp:false help:0xc0004900c0 showQuit:true quit:0xc00047ae69 showSessionDialog:false sessionDialog:0xc000319f40 showCommandDialog:false commandDialog:0xc00042d880 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000357e00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa540}"
time=2025-06-02T19:36:04.879-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T19:36:04.883-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc000544b40] loadedPages:map[chat:true] status:{app:0xc000249b90 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000249b90 showPermissions:false permissions:0xc000207408 showHelp:false help:0xc00011a1e0 showQuit:true quit:0xc0003890b9 showSessionDialog:false sessionDialog:0xc000319f40 showCommandDialog:false commandDialog:0xc0003e5520 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000547220 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac540}"
time=2025-06-02T19:44:20.524-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T19:44:20.529-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc0001f87d0] loadedPages:map[chat:true] status:{app:0xc000270cb0 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000270cb0 showPermissions:false permissions:0xc00022f408 showHelp:false help:0xc000490e70 showQuit:true quit:0xc000388ab9 showSessionDialog:false sessionDialog:0xc000319f00 showCommandDialog:false commandDialog:0xc0003e55e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00030bd60 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac500}"
time=2025-06-02T19:45:47.456-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T19:45:47.462-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc00035b9f0] loadedPages:map[chat:true] status:{app:0xc0000ec230 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0000ec230 showPermissions:false permissions:0xc0005e5408 showHelp:false help:0xc0005ad950 showQuit:true quit:0xc0005a0c09 showSessionDialog:false sessionDialog:0xc00012e440 showCommandDialog:false commandDialog:0xc0003c2160 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0002c74a0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00012e680 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00012e6c0}"
time=2025-06-02T19:47:11.433-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T19:47:11.438-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T19:48:43.841-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T19:48:43.841-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc0001f9040] loadedPages:map[chat:true] status:{app:0xc000270070 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000270070 showPermissions:false permissions:0xc00022f408 showHelp:false help:0xc000122090 showQuit:true quit:0xc000447c19 showSessionDialog:false sessionDialog:0xc000323b40 showCommandDialog:false commandDialog:0xc0003cb540 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00044d5e0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000323d80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323dc0}"
time=2025-06-02T19:48:57.679-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T19:48:57.685-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc000564be0] loadedPages:map[chat:true] status:{app:0xc000250d20 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000250d20 showPermissions:false permissions:0xc0004d6a08 showHelp:false help:0xc00061d5c0 showQuit:true quit:0xc0005578a9 showSessionDialog:false sessionDialog:0xc00032a640 showCommandDialog:false commandDialog:0xc0003e51e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000620aa0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc00032a880 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00032a8c0}"
+22 -145
View File
@@ -2,17 +2,14 @@ package main
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
zone "github.com/lrstanley/bubblezone"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/pkg/client"
)
@@ -20,18 +17,22 @@ import (
var Version = "dev"
func main() {
version := Version
if version != "dev" && !strings.HasPrefix(Version, "v") {
version = "v" + Version
}
url := os.Getenv("OPENCODE_SERVER")
httpClient, err := client.NewClientWithResponses(url)
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo client.AppInfo
err := json.Unmarshal([]byte(appInfoStr), &appInfo)
if err != nil {
slog.Error("Failed to create client", "error", err)
slog.Error("Failed to unmarshal app info", "error", err)
os.Exit(1)
}
paths, err := httpClient.PostPathGetWithResponse(context.Background())
if err != nil {
panic(err)
}
logfile := filepath.Join(paths.JSON200.Data, "log", "tui.log")
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
err := os.MkdirAll(filepath.Dir(logfile), 0755)
if err != nil {
@@ -48,26 +49,27 @@ func main() {
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
httpClient, err := client.NewClientWithResponses(url)
if err != nil {
slog.Error("Failed to create client", "error", err)
os.Exit(1)
}
// Create main context for the application
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
version := Version
if version != "dev" && !strings.HasPrefix(Version, "v") {
version = "v" + Version
}
app_, err := app.New(ctx, version, httpClient)
app_, err := app.New(ctx, version, appInfo, httpClient)
if err != nil {
panic(err)
}
// Set up the TUI
zone.NewGlobal()
program := tea.NewProgram(
tui.NewModel(app_),
// tea.WithMouseCellMotion(),
tea.WithKeyboardEnhancements(),
// tea.WithColorProfile(colorprofile.ANSI),
tea.WithAltScreen(),
tea.WithKeyboardEnhancements(),
tea.WithMouseCellMotion(),
)
eventClient, err := client.NewClient(url)
@@ -88,136 +90,11 @@ func main() {
}
}()
// Setup the subscriptions, this will send services events to the TUI
ch, cancelSubs := setupSubscriptions(app_, ctx)
// Create a context for the TUI message handler
tuiCtx, tuiCancel := context.WithCancel(ctx)
var tuiWg sync.WaitGroup
tuiWg.Add(1)
// Set up message handling for the TUI
go func() {
defer tuiWg.Done()
// defer logging.RecoverPanic("TUI-message-handler", func() {
// attemptTUIRecovery(program)
// })
for {
select {
case <-tuiCtx.Done():
slog.Info("TUI message handler shutting down")
return
case msg, ok := <-ch:
if !ok {
slog.Info("TUI message channel closed")
return
}
program.Send(msg)
}
}
}()
// Cleanup function for when the program exits
cleanup := func() {
// Cancel subscriptions first
cancelSubs()
// Then shutdown the app
app_.Shutdown()
// Then cancel TUI message handler
tuiCancel()
// Wait for TUI message handler to finish
tuiWg.Wait()
slog.Info("All goroutines cleaned up")
}
// Run the TUI
result, err := program.Run()
cleanup()
if err != nil {
slog.Error("TUI error", "error", err)
// return fmt.Errorf("TUI error: %v", err)
}
slog.Info("TUI exited", "result", result)
}
func setupSubscriber[T any](
ctx context.Context,
wg *sync.WaitGroup,
name string,
subscriber func(context.Context) <-chan pubsub.Event[T],
outputCh chan<- tea.Msg,
) {
wg.Add(1)
go func() {
defer wg.Done()
// defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
subCh := subscriber(ctx)
if subCh == nil {
slog.Warn("subscription channel is nil", "name", name)
return
}
for {
select {
case event, ok := <-subCh:
if !ok {
slog.Info("subscription channel closed", "name", name)
return
}
var msg tea.Msg = event
select {
case outputCh <- msg:
case <-time.After(2 * time.Second):
slog.Warn("message dropped due to slow consumer", "name", name)
case <-ctx.Done():
slog.Info("subscription cancelled", "name", name)
return
}
case <-ctx.Done():
slog.Info("subscription cancelled", "name", name)
return
}
}
}()
}
func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
ch := make(chan tea.Msg, 100)
wg := sync.WaitGroup{}
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
cleanupFunc := func() {
slog.Info("Cancelling all subscriptions")
cancel() // Signal all goroutines to stop
waitCh := make(chan struct{})
go func() {
// defer logging.RecoverPanic("subscription-cleanup", nil)
wg.Wait()
close(waitCh)
}()
select {
case <-waitCh:
slog.Info("All subscription goroutines completed successfully")
close(ch) // Only close after all writers are confirmed done
case <-time.After(5 * time.Second):
slog.Warn("Timed out waiting for some subscription goroutines to complete")
close(ch)
}
}
return ch, cleanupFunc
}
+1 -6
View File
@@ -5,14 +5,12 @@ go 1.24.0
require (
github.com/BurntSushi/toml v1.5.0
github.com/alecthomas/chroma/v2 v2.18.0
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
github.com/charmbracelet/x/ansi v0.8.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0
@@ -27,7 +25,6 @@ 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/bubbletea v1.3.4 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 // indirect
@@ -66,13 +63,11 @@ require (
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
+2 -13
View File
@@ -24,12 +24,8 @@ github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/
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/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
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 v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3/go.mod h1:ZFDg5oPjyRYrPAa3iFrtP1DO8xy+LUQxd9JFHEcuwJY=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
@@ -38,8 +34,8 @@ github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
@@ -69,8 +65,6 @@ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
@@ -126,16 +120,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -267,7 +257,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
File diff suppressed because one or more lines are too long
Binary file not shown.
+245 -129
View File
@@ -5,122 +5,210 @@ import (
"fmt"
"path/filepath"
"sort"
"strings"
"time"
"log/slog"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
var RootPath string
type App struct {
ConfigPath string
Config *config.Config
Client *client.ClientWithResponses
Provider *client.ProviderInfo
Model *client.ProviderModel
Session *client.SessionInfo
Messages []client.MessageInfo
Status status.Service
// UI state
filepickerOpen bool
completionDialogOpen bool
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
}
type AppInfo struct {
client.AppInfo
Version string
type SessionSelectedMsg = *client.SessionInfo
type ModelSelectedMsg struct {
Provider client.ProviderInfo
Model client.ModelInfo
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendMsg struct {
Text string
Attachments []Attachment
}
type CompletionDialogTriggeredMsg struct {
InitialValue string
}
type OptimisticMessageAddedMsg struct {
Message client.MessageInfo
}
var Info AppInfo
func New(
ctx context.Context,
version string,
appInfo client.AppInfo,
httpClient *client.ClientWithResponses,
) (*App, error) {
RootPath = appInfo.Path.Root
func New(ctx context.Context, version string, httpClient *client.ClientWithResponses) (*App, error) {
err := status.InitService()
if err != nil {
slog.Error("Failed to initialize status service", "error", err)
return nil, err
}
appInfoResponse, _ := httpClient.PostAppInfoWithResponse(ctx)
appInfo := appInfoResponse.JSON200
Info = AppInfo{Version: version}
Info.Git = appInfo.Git
Info.Path = appInfo.Path
Info.Time = appInfo.Time
Info.User = appInfo.User
providersResponse, err := httpClient.PostProviderListWithResponse(ctx)
configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
if err != nil {
return nil, err
}
providers := []client.ProviderInfo{}
var defaultProvider *client.ProviderInfo
var defaultModel *client.ProviderModel
for i, provider := range providersResponse.JSON200.Providers {
if i == 0 || provider.Id == "anthropic" {
defaultProvider = &providersResponse.JSON200.Providers[i]
if match, ok := providersResponse.JSON200.Default[provider.Id]; ok {
model := defaultProvider.Models[match]
defaultModel = &model
} else {
for _, model := range provider.Models {
defaultModel = &model
break
}
}
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,
}
providers = append(providers, provider)
}
if len(providers) == 0 {
return nil, fmt.Errorf("no providers found")
configInfo.Keybinds = &keybinds
}
appConfigPath := filepath.Join(Info.Path.Config, "tui.toml")
appConfig, err := config.LoadConfig(appConfigPath)
appStatePath := filepath.Join(appInfo.Path.State, "tui")
appState, err := config.LoadState(appStatePath)
if err != nil {
slog.Info("No TUI config found, using default values", "error", err)
appConfig = config.NewConfig("opencode", defaultProvider.Id, defaultModel.Id)
config.SaveConfig(appConfigPath, appConfig)
appState = config.NewState()
config.SaveState(appStatePath, appState)
}
var currentProvider *client.ProviderInfo
var currentModel *client.ProviderModel
for _, provider := range providers {
if provider.Id == appConfig.Provider {
currentProvider = &provider
if configInfo.Theme != nil {
appState.Theme = *configInfo.Theme
}
if configInfo.Model != nil {
splits := strings.Split(*configInfo.Model, "/")
appState.Provider = splits[0]
appState.Model = strings.Join(splits[1:], "/")
}
for _, model := range provider.Models {
if model.Id == appConfig.Model {
currentModel = &model
}
}
// Load themes from all directories
if err := theme.LoadThemesFromDirectories(
appInfo.Path.Config,
appInfo.Path.Root,
appInfo.Path.Cwd,
); err != nil {
slog.Warn("Failed to load themes from directories", "error", err)
}
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{
ConfigPath: appConfigPath,
Config: appConfig,
Client: httpClient,
Provider: currentProvider,
Model: currentModel,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Status: status.GetService(),
Info: appInfo,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Commands: commands.LoadFromConfig(configInfo),
}
theme.SetTheme(appConfig.Theme)
fileutil.Init()
return app, nil
}
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
var anthropic *client.ProviderInfo
for _, provider := range providersResponse.JSON200.Providers {
if provider.Id == "anthropic" {
anthropic = &provider
}
}
// 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
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
}
// TODO: handle no provider or model setup, yet
return ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
}
}
}
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
if match, ok := response.JSON200.Default[provider.Id]; ok {
model := provider.Models[match]
return &model
} else {
for _, model := range provider.Models {
return &model
}
}
return nil
}
type Attachment struct {
FilePath string
FileName string
@@ -137,8 +225,11 @@ func (a *App) IsBusy() bool {
return lastMessage.Metadata.Time.Completed == nil
}
func (a *App) SaveConfig() {
config.SaveConfig(a.ConfigPath, a.Config)
func (a *App) SaveState() {
err := config.SaveState(a.StatePath, a.State)
if err != nil {
slog.Error("Failed to save state", "error", err)
}
}
func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
@@ -146,31 +237,49 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
session, err := a.CreateSession(ctx)
if err != nil {
status.Error(err.Error())
// status.Error(err.Error())
return nil
}
a.Session = session
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
go func() {
// TODO: Handle no provider or model setup, yet
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
})
if err != nil {
status.Error(err.Error())
slog.Error("Failed to initialize project", "error", err)
// status.Error(err.Error())
}
if response != nil && response.StatusCode != 200 {
status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
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 {
go func() {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: 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)
}
}()
return nil
}
func (a *App) MarkProjectInitialized(ctx context.Context) error {
response, err := a.Client.PostAppInitialize(ctx)
if err != nil {
@@ -200,17 +309,10 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
if a.Session.Id == "" {
session, err := a.CreateSession(ctx)
if err != nil {
status.Error(err.Error())
return nil
return toast.NewErrorToast(err.Error())
}
a.Session = session
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
}
// TODO: Handle attachments when API supports them
if len(attachments) > 0 {
// For now, ignore attachments
// return "", fmt.Errorf("attachments not supported yet")
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
}
part := client.MessagePart{}
@@ -220,7 +322,26 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
})
parts := []client.MessagePart{part}
go func() {
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),
},
}
a.Messages = append(a.Messages, optimisticMessage)
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,
@@ -228,14 +349,17 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
ModelID: a.Model.Id,
})
if err != nil {
slog.Error("Failed to send message", "error", err)
status.Error(err.Error())
errormsg := fmt.Sprintf("failed to send message: %v", err)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to send message", "error", fmt.Sprintf("failed to send message: %d", response.StatusCode))
status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode))
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
@@ -248,12 +372,12 @@ func (a *App) Cancel(ctx context.Context, sessionID string) error {
})
if err != nil {
slog.Error("Failed to cancel session", "error", err)
status.Error(err.Error())
// 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))
// status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
}
return nil
@@ -279,6 +403,19 @@ func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
return sessions, nil
}
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{
SessionID: sessionID,
})
if err != nil {
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})
if err != nil {
@@ -310,27 +447,6 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
return providers.Providers, nil
}
// IsFilepickerOpen returns whether the filepicker is currently open
func (app *App) IsFilepickerOpen() bool {
return app.filepickerOpen
}
// SetFilepickerOpen sets the state of the filepicker
func (app *App) SetFilepickerOpen(open bool) {
app.filepickerOpen = open
}
// IsCompletionDialogOpen returns whether the completion dialog is currently open
func (app *App) IsCompletionDialogOpen() bool {
return app.completionDialogOpen
}
// SetCompletionDialogOpen sets the state of the completion dialog
func (app *App) SetCompletionDialogOpen(open bool) {
app.completionDialogOpen = open
}
// Shutdown performs a clean shutdown of the application
func (app *App) Shutdown() {
// TODO: cleanup?
}
// func (a *App) loadCustomKeybinds() {
//
// }
+281
View File
@@ -0,0 +1,281 @@
package commands
import (
"encoding/json"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/pkg/client"
)
type ExecuteCommandMsg Command
type ExecuteCommandsMsg []Command
type CommandExecutedMsg Command
type Keybinding struct {
RequiresLeader bool
Key string
}
func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
key := k.Key
key = strings.TrimSpace(key)
return key == msg.String() && (k.RequiresLeader == leader)
}
type CommandName string
type Command struct {
Name CommandName
Description string
Keybindings []Keybinding
Trigger string
}
func (c Command) Keys() []string {
var keys []string
for _, k := range c.Keybindings {
keys = append(keys, k.Key)
}
return keys
}
type CommandRegistry map[CommandName]Command
func (r CommandRegistry) Sorted() []Command {
var commands []Command
for _, command := range r {
commands = append(commands, command)
}
slices.SortFunc(commands, func(a, b Command) int {
if a.Name == AppExitCommand {
return 1
}
if b.Name == AppExitCommand {
return -1
}
return strings.Compare(string(a.Name), string(b.Name))
})
return commands
}
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
var matched []Command
for _, command := range r.Sorted() {
if command.Matches(msg, leader) {
matched = append(matched, command)
}
}
return matched
}
const (
AppHelpCommand CommandName = "app_help"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
SessionShareCommand CommandName = "session_share"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
ToolDetailsCommand CommandName = "tool_details"
ModelListCommand CommandName = "model_list"
ThemeListCommand CommandName = "theme_list"
ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
HistoryPreviousCommand CommandName = "history_previous"
HistoryNextCommand CommandName = "history_next"
MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
MessagesPreviousCommand CommandName = "messages_previous"
MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last"
AppExitCommand CommandName = "app_exit"
)
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
for _, binding := range k.Keybindings {
if binding.Matches(msg, leader) {
return true
}
}
return false
}
func parseBindings(bindings ...string) []Keybinding {
var parsedBindings []Keybinding
for _, binding := range bindings {
for p := range strings.SplitSeq(binding, ",") {
requireLeader := strings.HasPrefix(p, "<leader>")
keybinding := strings.ReplaceAll(p, "<leader>", "")
keybinding = strings.TrimSpace(keybinding)
parsedBindings = append(parsedBindings, Keybinding{
RequiresLeader: requireLeader,
Key: keybinding,
})
}
}
return parsedBindings
}
func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
defaults := []Command{
{
Name: AppHelpCommand,
Description: "show help",
Keybindings: parseBindings("<leader>h"),
Trigger: "help",
},
{
Name: EditorOpenCommand,
Description: "open editor",
Keybindings: parseBindings("<leader>e"),
Trigger: "editor",
},
{
Name: SessionNewCommand,
Description: "new session",
Keybindings: parseBindings("<leader>n"),
Trigger: "new",
},
{
Name: SessionListCommand,
Description: "list sessions",
Keybindings: parseBindings("<leader>l"),
Trigger: "sessions",
},
{
Name: SessionShareCommand,
Description: "share session",
Keybindings: parseBindings("<leader>s"),
Trigger: "share",
},
{
Name: SessionInterruptCommand,
Description: "interrupt session",
Keybindings: parseBindings("esc"),
},
{
Name: SessionCompactCommand,
Description: "compact the session",
Keybindings: parseBindings("<leader>c"),
Trigger: "compact",
},
{
Name: ToolDetailsCommand,
Description: "toggle tool details",
Keybindings: parseBindings("<leader>d"),
Trigger: "details",
},
{
Name: ModelListCommand,
Description: "list models",
Keybindings: parseBindings("<leader>m"),
Trigger: "models",
},
{
Name: ThemeListCommand,
Description: "list themes",
Keybindings: parseBindings("<leader>t"),
Trigger: "themes",
},
{
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
Keybindings: parseBindings("<leader>i"),
Trigger: "init",
},
{
Name: InputClearCommand,
Description: "clear input",
Keybindings: parseBindings("ctrl+c"),
},
{
Name: InputPasteCommand,
Description: "paste content",
Keybindings: parseBindings("ctrl+v"),
},
{
Name: InputSubmitCommand,
Description: "submit message",
Keybindings: parseBindings("enter"),
},
{
Name: InputNewlineCommand,
Description: "insert newline",
Keybindings: parseBindings("shift+enter", "ctrl+j"),
},
// {
// Name: HistoryPreviousCommand,
// Description: "previous prompt",
// Keybindings: parseBindings("up"),
// },
// {
// Name: HistoryNextCommand,
// Description: "next prompt",
// Keybindings: parseBindings("down"),
// },
{
Name: MessagesPageUpCommand,
Description: "page up",
Keybindings: parseBindings("pgup"),
},
{
Name: MessagesPageDownCommand,
Description: "page down",
Keybindings: parseBindings("pgdown"),
},
{
Name: MessagesHalfPageUpCommand,
Description: "half page up",
Keybindings: parseBindings("ctrl+alt+u"),
},
{
Name: MessagesHalfPageDownCommand,
Description: "half page down",
Keybindings: parseBindings("ctrl+alt+d"),
},
{
Name: MessagesPreviousCommand,
Description: "previous message",
Keybindings: parseBindings("ctrl+alt+k"),
},
{
Name: MessagesNextCommand,
Description: "next message",
Keybindings: parseBindings("ctrl+alt+j"),
},
{
Name: MessagesFirstCommand,
Description: "first message",
Keybindings: parseBindings("ctrl+g"),
},
{
Name: MessagesLastCommand,
Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"),
},
{
Name: AppExitCommand,
Description: "exit the app",
Keybindings: parseBindings("ctrl+c", "<leader>q"),
Trigger: "exit",
},
}
registry := make(CommandRegistry)
keybinds := map[string]string{}
marshalled, _ := json.Marshal(*config.Keybinds)
json.Unmarshal(marshalled, &keybinds)
for _, command := range defaults {
if keybind, ok := keybinds[string(command.Name)]; ok {
command.Keybindings = parseBindings(keybind)
}
registry[command.Name] = command
}
return registry
}
@@ -0,0 +1,102 @@
package completions
import (
"sort"
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type CommandCompletionProvider struct {
app *app.App
}
func NewCommandCompletionProvider(app *app.App) dialog.CompletionProvider {
return &CommandCompletionProvider{app: app}
}
func (c *CommandCompletionProvider) GetId() string {
return "commands"
}
func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Commands",
Value: "commands",
})
}
func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands"
}
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
spacer := strings.Repeat(" ", space)
title := " /" + cmd.Trigger + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
value := string(cmd.Name)
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: value,
})
}
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
t := theme.CurrentTheme()
commands := c.app.Commands
space := 1
for _, cmd := range c.app.Commands {
if lipgloss.Width(cmd.Trigger) > space {
space = lipgloss.Width(cmd.Trigger)
}
}
space += 2
sorted := commands.Sorted()
if query == "" {
// If no query, return all commands
items := []dialog.CompletionItemI{}
for _, cmd := range sorted {
if cmd.Trigger == "" {
continue
}
space := space - lipgloss.Width(cmd.Trigger)
items = append(items, getCommandCompletionItem(cmd, space, t))
}
return items, nil
}
// Use fuzzy matching for commands
var commandNames []string
commandMap := make(map[string]dialog.CompletionItemI)
for _, cmd := range sorted {
if cmd.Trigger == "" {
continue
}
space := space - lipgloss.Width(cmd.Trigger)
commandNames = append(commandNames, cmd.Trigger)
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space, t)
}
// Find fuzzy matches
matches := fuzzy.RankFind(query, commandNames)
// Sort by score (best matches first)
sort.Sort(matches)
// Convert matches to completion items
items := []dialog.CompletionItemI{}
for _, match := range matches {
if item, ok := commandMap[match.Target]; ok {
items = append(items, item)
}
}
return items, nil
}
@@ -1,18 +1,15 @@
package completions
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"context"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/pkg/client"
)
type filesAndFoldersContextGroup struct {
app *app.App
prefix string
}
@@ -27,143 +24,22 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
})
}
func processNullTerminatedOutput(outputBytes []byte) []string {
if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 {
outputBytes = outputBytes[:len(outputBytes)-1]
}
if len(outputBytes) == 0 {
return []string{}
}
split := bytes.Split(outputBytes, []byte{0})
matches := make([]string, 0, len(split))
for _, p := range split {
if len(p) == 0 {
continue
}
path := string(p)
path = filepath.Join(".", path)
if !fileutil.SkipHidden(path) {
matches = append(matches, path)
}
}
return matches
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
return "no matching files"
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case
cmdFzf := fileutil.GetFzfCmd(query)
var matches []string
// Case 1: Both rg and fzf available
if cmdRg != nil && cmdFzf != nil {
rgPipe, err := cmdRg.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err)
}
defer rgPipe.Close()
cmdFzf.Stdin = rgPipe
var fzfOut bytes.Buffer
var fzfErr bytes.Buffer
cmdFzf.Stdout = &fzfOut
cmdFzf.Stderr = &fzfErr
if err := cmdFzf.Start(); err != nil {
return nil, fmt.Errorf("failed to start fzf: %w", err)
}
errRg := cmdRg.Run()
errFzf := cmdFzf.Wait()
if errRg != nil {
status.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg))
}
if errFzf != nil {
if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []string{}, nil // No matches from fzf
}
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String())
}
matches = processNullTerminatedOutput(fzfOut.Bytes())
// Case 2: Only rg available
} else if cmdRg != nil {
status.Debug("Using Ripgrep with fuzzy match fallback for file completions")
var rgOut bytes.Buffer
var rgErr bytes.Buffer
cmdRg.Stdout = &rgOut
cmdRg.Stderr = &rgErr
if err := cmdRg.Run(); err != nil {
return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String())
}
allFiles := processNullTerminatedOutput(rgOut.Bytes())
matches = fuzzy.Find(query, allFiles)
// Case 3: Only fzf available
} else if cmdFzf != nil {
status.Debug("Using FZF with doublestar fallback for file completions")
files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
if err != nil {
return nil, fmt.Errorf("failed to list files for fzf: %w", err)
}
allFiles := make([]string, 0, len(files))
for _, file := range files {
if !fileutil.SkipHidden(file) {
allFiles = append(allFiles, file)
}
}
var fzfIn bytes.Buffer
for _, file := range allFiles {
fzfIn.WriteString(file)
fzfIn.WriteByte(0)
}
cmdFzf.Stdin = &fzfIn
var fzfOut bytes.Buffer
var fzfErr bytes.Buffer
cmdFzf.Stdout = &fzfOut
cmdFzf.Stderr = &fzfErr
if err := cmdFzf.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []string{}, nil
}
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String())
}
matches = processNullTerminatedOutput(fzfOut.Bytes())
// Case 4: Fallback to doublestar with fuzzy match
} else {
status.Debug("Using doublestar with fuzzy match for file completions")
allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
if err != nil {
return nil, fmt.Errorf("failed to glob files: %w", err)
}
filteredFiles := make([]string, 0, len(allFiles))
for _, file := range allFiles {
if !fileutil.SkipHidden(file) {
filteredFiles = append(filteredFiles, file)
}
}
matches = fuzzy.Find(query, filteredFiles)
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
Query: query,
})
if err != nil {
return []string{}, err
}
if response.JSON200 == nil {
return []string{}, nil
}
return matches, nil
return *response.JSON200, nil
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
@@ -184,8 +60,9 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C
return items, nil
}
func NewFileAndFolderContextGroup() dialog.CompletionProvider {
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
return &filesAndFoldersContextGroup{
app: app,
prefix: "file",
}
}
@@ -0,0 +1,32 @@
package completions
import (
"strings"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
)
type CompletionManager struct {
providers map[string]dialog.CompletionProvider
}
func NewCompletionManager(app *app.App) *CompletionManager {
return &CompletionManager{
providers: map[string]dialog.CompletionProvider{
"files": NewFileAndFolderContextGroup(app),
"commands": NewCommandCompletionProvider(app),
},
}
}
func (m *CompletionManager) DefaultProvider() dialog.CompletionProvider {
return m.providers["commands"]
}
func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider {
if strings.HasPrefix(input, "/") {
return m.providers["commands"]
}
return m.providers["files"]
}

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