Compare commits

..
195 Commits
Author SHA1 Message Date
Dax Raadandopencode 2090bab537 fix(tui): change messages layout toggle keybinding from <leader>m to <leader>p
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

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

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

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-26 17:32:00 -04:00
Jay V 3b6976a9c8 Merge branch 'rekram1-node-chore/update-config-docs' into dev 2025-06-26 17:24:03 -04:00
Jay V 863d5c1e8e docs: editing rules 2025-06-26 17:23:52 -04:00
adamdottv 97e19e9677 fix(tui): editor styles were off 2025-06-26 17:22:21 -04:00
adamdottv b27851461f feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottv 209687377a feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottv 90face1c09 fix(tui): editor width issues 2025-06-26 17:22:21 -04:00
adamdottv 936e2ce48b feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 17:22:21 -04:00
adamdottv 16ee8ee379 fix(tui): chat editor aesthetics 2025-06-26 17:22:21 -04:00
adamdottv ac39308dad fix(tui): visual issue with modal selected items in system theme 2025-06-26 17:22:21 -04:00
adamdottv 346b49219d chore: tui agents.md 2025-06-26 17:22:21 -04:00
Jay V d84c1f20c7 docs: social share 2025-06-26 17:22:17 -04:00
adamdottv dfb8777555 fix(tui): editor spinner colors 2025-06-26 17:21:53 -04:00
Jay V 008af18156 docs: share page responsive diff 2025-06-26 17:21:53 -04:00
adamdottv ab23167f80 docs: system theme 2025-06-26 17:21:53 -04:00
adamdottv b17ec46463 fix(tui): make opencode theme default 2025-06-26 17:21:53 -04:00
Adamandadamdottv 2e26b58d16 feat: default system theme (#419)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 17:21:53 -04:00
Mike Wallio 31b56e5a05 Fix undefined is not an object (evaluating 'G.title') (#395) 2025-06-26 17:21:53 -04:00
Juhani Pelli 47c401cf25 fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-26 17:21:53 -04:00
Dax Raad fab8dc9e6f more edit tool fixes 2025-06-26 17:21:53 -04:00
Dax Raad f39a2b1f16 integrate gemini-cli strategies for edit tool 2025-06-26 17:21:53 -04:00
Dax Raad 66830ced4e make edit tool more robust 2025-06-26 17:21:53 -04:00
Dax Raad 9d3fad754d ignore: typo 2025-06-26 17:21:53 -04:00
Dax Raad dcd3131f58 add output length errors 2025-06-26 17:21:53 -04:00
Dax Raad 3d02e07161 fix codex not working 2025-06-26 17:21:53 -04:00
Dax Raad 4dbc6a43a6 redirect uncaught errors to log file 2025-06-26 17:21:53 -04:00
adamdottv 5394b5188b fix(tui): editor styles were off
publish / publish (push) Has been cancelled
2025-06-26 15:12:26 -05:00
adamdottv 8e680b3957 feat(tui): more themes
publish / publish (push) Has been cancelled
2025-06-26 15:03:30 -05:00
adamdottv 1b8cd796d6 feat(tui): more themes 2025-06-26 14:54:32 -05:00
adamdottv 35fba793d0 fix(tui): editor width issues
publish / publish (push) Has been cancelled
2025-06-26 12:57:11 -05:00
adamdottv 5358d43b74 feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 12:47:17 -05:00
adamdottv f777347bac fix(tui): chat editor aesthetics 2025-06-26 12:44:44 -05:00
adamdottv 17c8b914df fix(tui): visual issue with modal selected items in system theme
publish / publish (push) Has been cancelled
2025-06-26 12:33:06 -05:00
adamdottv 43b467dd12 chore: tui agents.md 2025-06-26 12:28:29 -05:00
Jay V 0e0770921e docs: social share 2025-06-26 13:21:42 -04:00
adamdottv 8edbb74352 fix(tui): editor spinner colors 2025-06-26 12:21:20 -05:00
Jay V e6bfa95758 docs: share page responsive diff 2025-06-26 13:06:41 -04:00
adamdottv e4120b6287 docs: system theme 2025-06-26 11:33:02 -05:00
adamdottv ccbc9e00f2 fix(tui): make opencode theme default 2025-06-26 11:32:25 -05:00
Adamandadamdottv 7d13baadc8 feat: default system theme (#419)
publish / publish (push) Has been cancelled
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 10:16:07 -05:00
rekram1-node 9acc83697f chore: document AGENTS.md 2025-06-26 08:28:06 -05:00
Mike Wallio db24bf87c0 Fix undefined is not an object (evaluating 'G.title') (#395)
publish / publish (push) Has been cancelled
2025-06-25 19:40:09 -04:00
Juhani Pelli f4c0d2d2fd fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-25 19:39:51 -04:00
Dax Raad d240f4c676 more edit tool fixes
publish / publish (push) Has been cancelled
2025-06-25 19:22:54 -04:00
Dax Raad 9c90cdbe08 integrate gemini-cli strategies for edit tool
publish / publish (push) Has been cancelled
2025-06-25 17:56:14 -04:00
Dax Raad fc7af31fe5 make edit tool more robust
publish / publish (push) Has been cancelled
2025-06-25 17:10:48 -04:00
Dax Raad 2f8d23ec66 ignore: typo 2025-06-25 11:02:57 -04:00
Dax Raad 77ae3fb9b9 add output length errors 2025-06-25 11:02:09 -04:00
Dax Raad 4e7f6c47fd fix codex not working
publish / publish (push) Has been cancelled
2025-06-25 10:01:35 -04:00
Dax Raad 50469ed750 redirect uncaught errors to log file
publish / publish (push) Has been cancelled
2025-06-25 08:41:10 -04:00
Dax Raad aaab785493 better error message when bad directory is specified to start in 2025-06-24 22:28:25 -04:00
Dax Raadandopencode 9751937894 Enhance auth command with environment variable display and add models command
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-24 22:24:55 -04:00
Dax Raad 0fc8dfc77e do not print error on ctrl+c during prompts 2025-06-24 22:09:43 -04:00
Dax Raad 81b7df61ec ci: bun lock 2025-06-24 21:14:32 -04:00
Dax Raad 8217b96d4a ci: fix type issue 2025-06-24 21:12:32 -04:00
Dax Raad 7dd0918d32 remove accidental opanai autoloader
publish / publish (push) Has been cancelled
2025-06-24 21:11:11 -04:00
Dax Raad 4b26b43855 added opencode serve command
publish / publish (push) Has been cancelled
2025-06-24 20:52:09 -04:00
Jay V 9d7cfda9fe docs: share page styles 2025-06-24 19:34:35 -04:00
Jay V a3cf18c905 docs: share page bash tool output 2025-06-24 19:28:51 -04:00
Aiden Cline 0b1a8ae699 fix: file completions replaced wrong text when paths overlap (#378) 2025-06-24 18:13:15 -05:00
Dax Raad eb70b1e5c8 docs: windows instructions
publish / publish (push) Has been cancelled
2025-06-24 18:54:59 -04:00
Dax Raad 00a3d818b6 ci: windows 2025-06-24 18:46:43 -04:00
Dax Raad 2384c7e734 ci: windows 2025-06-24 18:40:36 -04:00
Dax Raad 1bad3d9894 ci: windows 2025-06-24 18:27:57 -04:00
Dax Raad 4f715e66dc ci: windows 2025-06-24 18:13:15 -04:00
DaxandMatthew Glazar ec001ca02f windows fixes (#374)
Co-authored-by: Matthew Glazar <strager.nds@gmail.com>
2025-06-24 18:05:04 -04:00
Jay a2d3b9f0c8 docs: Share page diff view improvements (#373) 2025-06-24 17:11:43 -04:00
Dax Raad 9cfb6ff964 ignore: revert
publish / publish (push) Has been cancelled
2025-06-24 14:59:27 -04:00
Dax Raad 6ed661c140 ci: upgrade bun 2025-06-24 14:42:25 -04:00
Dax Raad 9dc00edfc9 potential fix for failing to install provider package on first run 2025-06-24 14:33:35 -04:00
Jay V e063bf888e docs: share code blocks in markdown 2025-06-24 13:53:59 -04:00
178 changed files with 11142 additions and 9621 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
end_of_line = lf
indent_style = space
indent_size = 2
max_line_length = 80
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- uses: oven-sh/setup-bun@v1 - uses: oven-sh/setup-bun@v1
with: with:
bun-version: latest bun-version: 1.2.17
- run: bun install - run: bun install
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@v2
with: with:
bun-version: 1.2.16 bun-version: 1.2.17
- name: Install makepkg - name: Install makepkg
run: | run: |
+32
View File
@@ -0,0 +1,32 @@
name: stats
on:
schedule:
- cron: "0 12 * * *" # Run daily at 12:00 UTC
workflow_dispatch: # Allow manual trigger
jobs:
stats:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Run stats script
run: bun scripts/stats.ts
- name: Commit stats
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add STATS.md
git diff --staged --quiet || git commit -m "Update download stats $(date -I)"
git push
+8 -16
View File
@@ -1,9 +1,9 @@
<p align="center"> <p align="center">
<a href="https://opencode.ai"> <a href="https://opencode.ai">
<picture> <picture>
<source srcset="packages/web/src/assets/logo-dark.svg" media="(prefers-color-scheme: dark)"> <source srcset="packages/web/src/assets/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/web/src/assets/logo-light.svg" media="(prefers-color-scheme: light)"> <source srcset="packages/web/src/assets/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/web/src/assets/logo-light.svg" alt="opencode logo"> <img src="packages/web/src/assets/logo-ornate-light.svg" alt="opencode logo">
</picture> </picture>
</a> </a>
</p> </p>
@@ -14,7 +14,7 @@
<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> <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> </p>
[![opencode Terminal UI](packages/web/src/assets/themes/opencode.png)](https://opencode.ai) [![opencode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
--- ---
@@ -40,6 +40,9 @@ For more info on how to configure opencode [**head over to our docs**](https://o
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes. For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
> **Note**: Please talk to us via github issues before spending time working on
> a new feature
To run opencode locally you need. To run opencode locally you need.
- Bun - Bun
@@ -54,14 +57,7 @@ $ bun run packages/opencode/src/index.ts
#### Development Notes #### 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: **API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients.
```bash
$ cd packages/tui
$ go generate ./pkg/client/
```
This updates the generated Go client code that the TUI uses to communicate with the backend server.
### FAQ ### FAQ
@@ -74,10 +70,6 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. - A 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. - A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
#### What about Windows support?
There are some minor problems blocking opencode from working on windows. We are working on on them now. You'll need to use WSL for now.
#### What's the other repo? #### What's the other repo?
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466). 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).
+8
View File
@@ -0,0 +1,8 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | --------------- | --------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
+30 -4
View File
@@ -19,7 +19,10 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "0.0.0", "version": "0.0.5",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": { "dependencies": {
"@clack/prompts": "0.11.0", "@clack/prompts": "0.11.0",
"@flystorage/file-storage": "1.1.0", "@flystorage/file-storage": "1.1.0",
@@ -33,6 +36,7 @@
"env-paths": "3.0.0", "env-paths": "3.0.0",
"hono": "4.7.10", "hono": "4.7.10",
"hono-openapi": "0.4.8", "hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2", "open": "10.1.2",
"remeda": "2.22.3", "remeda": "2.22.3",
"ts-lsp-client": "1.0.3", "ts-lsp-client": "1.0.3",
@@ -78,7 +82,7 @@
"sharp": "0.32.5", "sharp": "0.32.5",
"shiki": "3.4.2", "shiki": "3.4.2",
"solid-js": "1.9.7", "solid-js": "1.9.7",
"toolbeam-docs-theme": "0.3.0", "toolbeam-docs-theme": "0.4.1",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "catalog:", "@types/node": "catalog:",
@@ -538,6 +542,8 @@
"astro-expressive-code": ["astro-expressive-code@0.41.2", "", { "dependencies": { "rehype-expressive-code": "^0.41.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-HN0jWTnhr7mIV/2e6uu4PPRNNo/k4UEgTLZqbp3MrHU+caCARveG2yZxaZVBmxyiVdYqW5Pd3u3n2zjnshixbw=="], "astro-expressive-code": ["astro-expressive-code@0.41.2", "", { "dependencies": { "rehype-expressive-code": "^0.41.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-HN0jWTnhr7mIV/2e6uu4PPRNNo/k4UEgTLZqbp3MrHU+caCARveG2yZxaZVBmxyiVdYqW5Pd3u3n2zjnshixbw=="],
"async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
@@ -630,6 +636,8 @@
"ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="], "ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
"clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
@@ -666,6 +674,8 @@
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
@@ -722,6 +732,8 @@
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
"diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="], "direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
@@ -936,6 +948,8 @@
"ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="], "ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="], "import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@@ -984,6 +998,8 @@
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isomorphic-git": ["isomorphic-git@1.32.1", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "path-browserify": "^1.0.1", "pify": "^4.0.1", "readable-stream": "^3.4.0", "sha.js": "^2.4.9", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-NZCS7qpLkCZ1M/IrujYBD31sM6pd/fMVArK4fz4I7h6m0rUW2AsYU7S7zXeABuHL6HIfW6l53b4UQ/K441CQjg=="],
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="], "jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="], "jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
@@ -1166,6 +1182,8 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minimisted": ["minimisted@2.0.1", "", { "dependencies": { "minimist": "^1.2.5" } }, "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"mri": ["mri@1.1.4", "", {}, "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w=="], "mri": ["mri@1.1.4", "", {}, "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w=="],
@@ -1244,7 +1262,7 @@
"pagefind": ["pagefind@1.3.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.3.0", "@pagefind/darwin-x64": "1.3.0", "@pagefind/linux-arm64": "1.3.0", "@pagefind/linux-x64": "1.3.0", "@pagefind/windows-x64": "1.3.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw=="], "pagefind": ["pagefind@1.3.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.3.0", "@pagefind/darwin-x64": "1.3.0", "@pagefind/linux-arm64": "1.3.0", "@pagefind/linux-x64": "1.3.0", "@pagefind/windows-x64": "1.3.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@@ -1254,6 +1272,8 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -1264,6 +1284,8 @@
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
"pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="], "pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="],
"pino-abstract-transport": ["pino-abstract-transport@0.5.0", "", { "dependencies": { "duplexify": "^4.1.2", "split2": "^4.0.0" } }, "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ=="], "pino-abstract-transport": ["pino-abstract-transport@0.5.0", "", { "dependencies": { "duplexify": "^4.1.2", "split2": "^4.0.0" } }, "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ=="],
@@ -1414,6 +1436,8 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sha.js": ["sha.js@2.4.11", "", { "dependencies": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" }, "bin": { "sha.js": "./bin.js" } }, "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ=="],
"sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="], "sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="],
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="], "shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
@@ -1522,7 +1546,7 @@
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="], "token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="], "toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.1", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-lTI4dHZaVNQky29m7sb36Oy4tWPwxsCuFxFjF8hgGW0vpV+S6qPvI9SwsJFvdE/OHO5DoI7VMbryV1pxZHkkHQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -1790,6 +1814,8 @@
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="], "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],
+17 -1
View File
@@ -1,3 +1,19 @@
{ {
"$schema": "https://opencode.ai/config.json" "$schema": "https://opencode.ai/config.json",
"experimental": {
"hook": {
"file_edited": {
".json": [
{
"command": ["bun", "run", "prettier", "$FILE"]
}
]
},
"session_completed": [
{
"command": ["touch", "./node_modules/foo"]
}
]
}
}
} }
+28 -10
View File
@@ -35,8 +35,7 @@ export class SyncServer extends DurableObject<Env> {
ws.close(code, "Durable Object is closing WebSocket") ws.close(code, "Durable Object is closing WebSocket")
} }
async publish(secret: string, key: string, content: any) { async publish(key: string, content: any) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
const sessionID = await this.getSessionID() const sessionID = await this.getSessionID()
if ( if (
!key.startsWith(`session/info/${sessionID}`) && !key.startsWith(`session/info/${sessionID}`) &&
@@ -76,6 +75,10 @@ export class SyncServer extends DurableObject<Env> {
.map(([key, content]) => ({ key, content })) .map(([key, content]) => ({ key, content }))
} }
public async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
private async getSecret() { private async getSecret() {
return this.ctx.storage.get<string>("secret") return this.ctx.storage.get<string>("secret")
} }
@@ -84,15 +87,19 @@ export class SyncServer extends DurableObject<Env> {
return this.ctx.storage.get<string>("sessionID") return this.ctx.storage.get<string>("sessionID")
} }
async clear(secret: string) { async clear() {
await this.assertSecret(secret) const sessionID = await this.getSessionID()
const list = await this.env.Bucket.list({
prefix: `session/message/${sessionID}/`,
limit: 1000,
})
for (const item of list.objects) {
await this.env.Bucket.delete(item.key)
}
await this.env.Bucket.delete(`session/info/${sessionID}`)
await this.ctx.storage.deleteAll() await this.ctx.storage.deleteAll()
} }
private async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
static shortName(id: string) { static shortName(id: string) {
return id.substring(id.length - 8) return id.substring(id.length - 8)
} }
@@ -134,7 +141,17 @@ export default {
const secret = body.secret const secret = body.secret
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID)) const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
const stub = env.SYNC_SERVER.get(id) const stub = env.SYNC_SERVER.get(id)
await stub.clear(secret) await stub.assertSecret(secret)
await stub.clear()
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
}
if (request.method === "POST" && method === "share_delete_admin") {
const id = env.SYNC_SERVER.idFromName("oVF8Rsiv")
const stub = env.SYNC_SERVER.get(id)
await stub.clear()
return new Response(JSON.stringify({}), { return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}) })
@@ -150,7 +167,8 @@ export default {
const name = SyncServer.shortName(body.sessionID) const name = SyncServer.shortName(body.sessionID)
const id = env.SYNC_SERVER.idFromName(name) const id = env.SYNC_SERVER.idFromName(name)
const stub = env.SYNC_SERVER.get(id) const stub = env.SYNC_SERVER.get(id)
await stub.publish(body.secret, body.key, body.content) await stub.assertSecret(body.secret)
await stub.publish(body.key, body.content)
return new Response(JSON.stringify({}), { return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}) })
+8 -8
View File
@@ -6,20 +6,20 @@
import "sst" import "sst"
declare module "sst" { declare module "sst" {
export interface Resource { export interface Resource {
"Web": { Web: {
"type": "sst.cloudflare.Astro" type: "sst.cloudflare.Astro"
"url": string url: string
} }
} }
} }
// cloudflare // cloudflare
import * as cloudflare from "@cloudflare/workers-types"; import * as cloudflare from "@cloudflare/workers-types"
declare module "sst" { declare module "sst" {
export interface Resource { export interface Resource {
"Api": cloudflare.Service Api: cloudflare.Service
"Bucket": cloudflare.R2Bucket Bucket: cloudflare.R2Bucket
} }
} }
import "sst" import "sst"
export {} export {}
+1 -2
View File
@@ -7,7 +7,6 @@
- **Typecheck**: `bun run typecheck` (npm run typecheck) - **Typecheck**: `bun run typecheck` (npm run typecheck)
- **Test**: `bun test` (runs all tests) - **Test**: `bun test` (runs all tests)
- **Single test**: `bun test test/tool/tool.test.ts` (specific test file) - **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 ## Code Style
@@ -38,4 +37,4 @@
- **Validation**: All inputs validated with Zod schemas - **Validation**: All inputs validated with Zod schemas
- **Logging**: Use `Log.create({ service: "name" })` pattern - **Logging**: Use `Log.create({ service: "name" })` pattern
- **Storage**: Use `Storage` namespace for persistence - **Storage**: Use `Storage` namespace for persistence
- **API Client**: Go TUI communicates with TypeScript server via generated client. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, run `cd packages/tui && go generate ./pkg/client/` to update the Go client code and OpenAPI spec. - **API Client**: Go TUI communicates with TypeScript server via stainless SDK. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, ask the user to generate a new client SDK to proceed with client-side changes.
+1 -1
View File
@@ -49,7 +49,7 @@ else
done done
if [ -z "$resolved" ]; then if [ -z "$resolved" ]; then
printf "It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2 printf "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
exit 1 exit 1
fi fi
fi fi
+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%" %*
+80 -20
View File
@@ -183,6 +183,9 @@
"temperature": { "temperature": {
"type": "boolean" "type": "boolean"
}, },
"tool_call": {
"type": "boolean"
},
"cost": { "cost": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -199,10 +202,7 @@
"type": "number" "type": "number"
} }
}, },
"required": [ "required": ["input", "output"],
"input",
"output"
],
"additionalProperties": false "additionalProperties": false
}, },
"limit": { "limit": {
@@ -215,14 +215,15 @@
"type": "number" "type": "number"
} }
}, },
"required": [ "required": ["context", "output"],
"context",
"output"
],
"additionalProperties": false "additionalProperties": false
}, },
"id": { "id": {
"type": "string" "type": "string"
},
"options": {
"type": "object",
"additionalProperties": {}
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -233,9 +234,7 @@
"additionalProperties": {} "additionalProperties": {}
} }
}, },
"required": [ "required": ["models"],
"models"
],
"additionalProperties": false "additionalProperties": false
}, },
"description": "Custom provider configurations and model overrides" "description": "Custom provider configurations and model overrides"
@@ -265,12 +264,13 @@
"type": "string" "type": "string"
}, },
"description": "Environment variables to set when running the MCP server" "description": "Environment variables to set when running the MCP server"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
} }
}, },
"required": [ "required": ["type", "command"],
"type",
"command"
],
"additionalProperties": false "additionalProperties": false
}, },
{ {
@@ -284,19 +284,79 @@
"url": { "url": {
"type": "string", "type": "string",
"description": "URL of the remote MCP server" "description": "URL of the remote MCP server"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
} }
}, },
"required": [ "required": ["type", "url"],
"type",
"url"
],
"additionalProperties": false "additionalProperties": false
} }
] ]
}, },
"description": "MCP (Model Context Protocol) server configurations" "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, "additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#" "$schema": "http://json-schema.org/draft-07/schema#"
} }
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0", "version": "0.0.5",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -8,6 +8,9 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run ./src/index.ts" "dev": "bun run ./src/index.ts"
}, },
"bin": {
"opencode": "./bin/opencode"
},
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
@@ -34,6 +37,7 @@
"env-paths": "3.0.0", "env-paths": "3.0.0",
"hono": "4.7.10", "hono": "4.7.10",
"hono-openapi": "0.4.8", "hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2", "open": "10.1.2",
"remeda": "2.22.3", "remeda": "2.22.3",
"ts-lsp-client": "1.0.3", "ts-lsp-client": "1.0.3",
+14 -10
View File
@@ -29,7 +29,7 @@ const targets = [
["linux", "x64"], ["linux", "x64"],
["darwin", "x64"], ["darwin", "x64"],
["darwin", "arm64"], ["darwin", "arm64"],
// ["windows", "x64"], ["windows", "x64"],
] ]
await $`rm -rf dist` await $`rm -rf dist`
@@ -110,6 +110,7 @@ if (!snapshot) {
return ( return (
!lower.includes("ignore:") && !lower.includes("ignore:") &&
!lower.includes("ci:") && !lower.includes("ci:") &&
!lower.includes("wip:") &&
!lower.includes("docs:") && !lower.includes("docs:") &&
!lower.includes("doc:") !lower.includes("doc:")
) )
@@ -142,7 +143,7 @@ if (!snapshot) {
"# Maintainer: dax", "# Maintainer: dax",
"# Maintainer: adam", "# Maintainer: adam",
"", "",
"pkgname='opencode-bin'", "pkgname='${pkg}'",
`pkgver=${version.split("-")[0]}`, `pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')", "options=('!debug' '!strip')",
"pkgrel=1", "pkgrel=1",
@@ -166,14 +167,17 @@ if (!snapshot) {
"", "",
].join("\n") ].join("\n")
await $`rm -rf ./dist/aur-opencode-bin` for (const pkg of ["opencode", "opencode-bin"]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/opencode-bin.git ./dist/aur-opencode-bin` await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
await Bun.file("./dist/aur-opencode-bin/PKGBUILD").write(pkgbuild) await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(
await $`cd ./dist/aur-opencode-bin && makepkg --printsrcinfo > .SRCINFO` pkgbuild.replace("${pkg}", pkg),
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO` )
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"` await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
if (!dry) await $`cd ./dist/aur-opencode-bin && git push` await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
}
// Homebrew formula // Homebrew formula
const homebrewFormula = [ const homebrewFormula = [
+43 -23
View File
@@ -13,6 +13,7 @@ export namespace App {
export const Info = z export const Info = z
.object({ .object({
user: z.string(), user: z.string(),
hostname: z.string(),
git: z.boolean(), git: z.boolean(),
path: z.object({ path: z.object({
config: z.string(), config: z.string(),
@@ -26,15 +27,28 @@ export namespace App {
}), }),
}) })
.openapi({ .openapi({
ref: "App.Info", ref: "App",
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
const ctx = Context.create<Awaited<ReturnType<typeof create>>>("app") const ctx = Context.create<{
info: Info
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
}>("app")
export const use = ctx.use
const APP_JSON = "app.json" const APP_JSON = "app.json"
async function create(input: { cwd: string }) { export type Input = {
cwd: string
}
export const provideExisting = ctx.provide
export async function provide<T>(
input: Input,
cb: (app: App.Info) => Promise<T>,
) {
log.info("creating", { log.info("creating", {
cwd: input.cwd, cwd: input.cwd,
}) })
@@ -46,7 +60,7 @@ export namespace App {
const data = path.join( const data = path.join(
Global.Path.data, Global.Path.data,
"project", "project",
git ? git.split(path.sep).filter(Boolean).join("-") : "global", git ? directory(git) : "global",
) )
const stateFile = Bun.file(path.join(data, APP_JSON)) const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as { const state = (await stateFile.json().catch(() => ({}))) as {
@@ -62,8 +76,11 @@ export namespace App {
} }
>() >()
const root = git ?? input.cwd
const info: Info = { const info: Info = {
user: os.userInfo().username, user: os.userInfo().username,
hostname: os.hostname(),
time: { time: {
initialized: state.initialized, initialized: state.initialized,
}, },
@@ -72,16 +89,27 @@ export namespace App {
config: Global.Path.config, config: Global.Path.config,
state: Global.Path.state, state: Global.Path.state,
data, data,
root: git ?? input.cwd, root,
cwd: input.cwd, cwd: input.cwd,
}, },
} }
const result = { const app = {
services, services,
info, info,
} }
return result return ctx.provide(app, async () => {
try {
const result = await cb(app.info)
return result
} finally {
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
}
})
} }
export function state<State>( export function state<State>(
@@ -107,22 +135,6 @@ export namespace App {
return ctx.use().info return ctx.use().info
} }
export async function provide<T>(
input: { cwd: string },
cb: (app: Info) => Promise<T>,
) {
const app = await create(input)
return ctx.provide(app, async () => {
const result = await cb(app.info)
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
return result
})
}
export async function initialize() { export async function initialize() {
const { info } = ctx.use() const { info } = ctx.use()
info.time.initialized = Date.now() info.time.initialized = Date.now()
@@ -133,4 +145,12 @@ export namespace App {
}), }),
) )
} }
function directory(input: string): string {
return input
.split(path.sep)
.filter(Boolean)
.join("-")
.replace(/[^A-Za-z0-9_]/g, "-")
}
} }
+1 -1
View File
@@ -3,7 +3,7 @@ import { lazy } from "../util/lazy"
import path from "path" import path from "path"
export const AuthCopilot = lazy(async () => { export const AuthCopilot = lazy(async () => {
const file = Bun.file(path.join(Global.Path.cache, "copilot.ts")) const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
const response = fetch( const response = fetch(
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts", "https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
) )
+14 -9
View File
@@ -3,6 +3,7 @@ import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import path from "path" import path from "path"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { readableStreamToText } from "bun"
export namespace BunProc { export namespace BunProc {
const log = Log.create({ service: "bun" }) const log = Log.create({ service: "bun" })
@@ -13,7 +14,7 @@ export namespace BunProc {
) { ) {
log.info("running", { log.info("running", {
cmd: [which(), ...cmd], cmd: [which(), ...cmd],
options, ...options,
}) })
const result = Bun.spawn([which(), ...cmd], { const result = Bun.spawn([which(), ...cmd], {
...options, ...options,
@@ -25,7 +26,14 @@ export namespace BunProc {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
}) })
const code = await result.exited const code = await result.exited;
const stdout = result.stdout ? typeof result.stdout === "number" ? result.stdout : await readableStreamToText(result.stdout) : undefined
const stderr = result.stderr ? typeof result.stderr === "number" ? result.stderr : await readableStreamToText(result.stderr) : undefined
log.info("done", {
code,
stdout,
stderr,
})
if (code !== 0) { if (code !== 0) {
throw new Error(`Command failed with exit code ${result.exitCode}`) throw new Error(`Command failed with exit code ${result.exitCode}`)
} }
@@ -53,13 +61,10 @@ export namespace BunProc {
if (parsed.dependencies[pkg] === version) return mod if (parsed.dependencies[pkg] === version) return mod
parsed.dependencies[pkg] = version parsed.dependencies[pkg] = version
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2)) await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
await BunProc.run( await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
["install", "--registry", "https://registry.npmjs.org/"], cwd: Global.Path.cache,
{ }).catch((e) => {
cwd: Global.Path.cache, throw new InstallFailedError(
},
).catch((e) => {
new InstallFailedError(
{ pkg, version }, { pkg, version },
{ {
cause: e, cause: e,
+4 -2
View File
@@ -49,7 +49,7 @@ export namespace Bus {
) )
} }
export function publish<Definition extends EventDefinition>( export async function publish<Definition extends EventDefinition>(
def: Definition, def: Definition,
properties: z.output<Definition["properties"]>, properties: z.output<Definition["properties"]>,
) { ) {
@@ -60,12 +60,14 @@ export namespace Bus {
log.info("publishing", { log.info("publishing", {
type: def.type, type: def.type,
}) })
const pending = []
for (const key of [def.type, "*"]) { for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key) const match = state().subscriptions.get(key)
for (const sub of match ?? []) { for (const sub of match ?? []) {
sub(payload) pending.push(sub(payload))
} }
} }
return Promise.all(pending)
} }
export function subscribe<Definition extends EventDefinition>( export function subscribe<Definition extends EventDefinition>(
+21
View File
@@ -0,0 +1,21 @@
import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { FileWatcher } from "../file/watch"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Share } from "../share/share"
export async function bootstrap<T>(
input: App.Input,
cb: (app: App.Info) => Promise<T>,
) {
return App.provide(input, async (app) => {
Share.init()
Format.init()
ConfigHooks.init()
LSP.init()
FileWatcher.init()
return cb(app)
})
}
+35 -2
View File
@@ -7,6 +7,9 @@ import open from "open"
import { UI } from "../ui" import { UI } from "../ui"
import { ModelsDev } from "../../provider/models" import { ModelsDev } from "../../provider/models"
import { map, pipe, sortBy, values } from "remeda" import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Global } from "../../global"
export const AuthCommand = cmd({ export const AuthCommand = cmd({
command: "auth", command: "auth",
@@ -26,16 +29,46 @@ export const AuthListCommand = cmd({
describe: "list providers", describe: "list providers",
async handler() { async handler() {
UI.empty() 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 results = await Auth.all().then((x) => Object.entries(x))
const database = await ModelsDev.get() const database = await ModelsDev.get()
for (const [providerID, result] of results) { for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID 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`) 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`)
}
}, },
}) })
@@ -0,0 +1,37 @@
import { File } from "../../../file"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
const FileReadCommand = cmd({
command: "read <path>",
builder: (yargs) =>
yargs.positional("path", {
type: "string",
demandOption: true,
description: "File path to read",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const content = await File.read(args.path)
console.log(content)
})
},
})
const FileStatusCommand = cmd({
command: "status",
builder: (yargs) => yargs,
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const status = await File.status()
console.log(JSON.stringify(status, null, 2))
})
},
})
export const FileCommand = cmd({
command: "file",
builder: (yargs) =>
yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
async handler() {},
})
@@ -0,0 +1,28 @@
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
import { SnapshotCommand } from "./snapshot"
export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)
.command(SnapshotCommand)
.command({
command: "wait",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1_000 * 60 * 60 * 24),
)
})
},
})
.demandCommand(),
async handler() {},
})
@@ -0,0 +1,37 @@
import { LSP } from "../../../lsp"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
export const LSPCommand = cmd({
command: "lsp",
builder: (yargs) =>
yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
async handler() {},
})
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
export const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) =>
yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
})
},
})
@@ -0,0 +1,87 @@
import { App } from "../../../app/app"
import { Ripgrep } from "../../../file/ripgrep"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const RipgrepCommand = cmd({
command: "rg",
builder: (yargs) =>
yargs
.command(TreeCommand)
.command(FilesCommand)
.command(SearchCommand)
.demandCommand(),
async handler() {},
})
const TreeCommand = cmd({
command: "tree",
builder: (yargs) =>
yargs.option("limit", {
type: "number",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
})
},
})
const FilesCommand = cmd({
command: "files",
builder: (yargs) =>
yargs
.option("query", {
type: "string",
description: "Filter files by query",
})
.option("glob", {
type: "string",
description: "Glob pattern to match files",
})
.option("limit", {
type: "number",
description: "Limit number of results",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
const files = await Ripgrep.files({
cwd: app.path.cwd,
query: args.query,
glob: args.glob,
limit: args.limit,
})
console.log(files.join("\n"))
})
},
})
const SearchCommand = cmd({
command: "search <pattern>",
builder: (yargs) =>
yargs
.positional("pattern", {
type: "string",
demandOption: true,
description: "Search pattern",
})
.option("glob", {
type: "array",
description: "File glob patterns",
})
.option("limit", {
type: "number",
description: "Limit number of results",
}),
async handler(args) {
const results = await Ripgrep.search({
cwd: process.cwd(),
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
})
console.log(JSON.stringify(results, null, 2))
},
})
@@ -0,0 +1,39 @@
import { Snapshot } from "../../../snapshot"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
builder: (yargs) =>
yargs
.command(SnapshotCreateCommand)
.command(SnapshotRestoreCommand)
.demandCommand(),
async handler() {},
})
export const SnapshotCreateCommand = cmd({
command: "create",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const result = await Snapshot.create("test")
console.log(result)
})
},
})
export const SnapshotRestoreCommand = cmd({
command: "restore <commit>",
builder: (yargs) =>
yargs.positional("commit", {
type: "string",
description: "commit",
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await Snapshot.restore("test", args.commit)
console.log("restored")
})
},
})
+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}`)
}
}
})
},
})
+94 -96
View File
@@ -1,14 +1,13 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { App } from "../../app/app"
import { Bus } from "../../bus" import { Bus } from "../../bus"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Session } from "../../session" import { Session } from "../../session"
import { Share } from "../../share/share"
import { Message } from "../../session/message" import { Message } from "../../session/message"
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Flag } from "../../flag/flag" import { Flag } from "../../flag/flag"
import { Config } from "../../config/config" import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
const TOOL: Record<string, [string, string]> = { const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -56,110 +55,109 @@ export const RunCommand = cmd({
}, },
handler: async (args) => { handler: async (args) => {
const message = args.message.join(" ") const message = args.message.join(" ")
await App.provide( await bootstrap({ cwd: process.cwd() }, async () => {
{ const session = await (async () => {
cwd: process.cwd(), if (args.continue) {
}, const first = await Session.list().next()
async () => { if (first.done) return
await Share.init() return first.value
const session = await (async () => {
if (args.continue) {
const first = await Session.list().next()
if (first.done) return
return first.value
}
if (args.session) return Session.get(args.session)
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
} }
UI.empty() if (args.session) return Session.get(args.session)
UI.println(UI.logo())
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
const cfg = await Config.get() return Session.create()
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) { })()
await Session.share(session.id)
UI.println( if (!session) {
UI.Style.TEXT_INFO_BOLD + 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/" + "~ https://opencode.ai/s/" +
session.id.slice(-8), session.id.slice(-8),
)
}
UI.empty()
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
) )
UI.empty() }
UI.empty()
function printEvent(color: string, type: string, title: string) { const { providerID, modelID } = args.model
UI.println( ? Provider.parseModel(args.model)
color + `|`, : await Provider.defaultModel()
UI.Style.TEXT_NORMAL + UI.println(
UI.Style.TEXT_DIM + UI.Style.TEXT_NORMAL_BOLD + "@ ",
` ${type.padEnd(7, " ")}`, UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
"", )
UI.Style.TEXT_NORMAL + title, UI.empty()
)
function printEvent(color: string, type: string, title: string) {
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
}
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
if (evt.properties.sessionID !== session.id) return
const part = evt.properties.part
const message = await Session.getMessage(
evt.properties.sessionID,
evt.properties.messageID,
)
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
const metadata = message.metadata.tool[part.toolInvocation.toolCallId]
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD,
]
printEvent(color, tool, metadata?.title || "Unknown")
} }
Bus.subscribe(Message.Event.PartUpdated, async (evt) => { if (part.type === "text") {
if (evt.properties.sessionID !== session.id) return if (part.text.includes("\n")) {
const part = evt.properties.part UI.empty()
const message = await Session.getMessage( UI.println(part.text)
evt.properties.sessionID, UI.empty()
evt.properties.messageID, return
)
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
const metadata =
message.metadata.tool[part.toolInvocation.toolCallId]
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD,
]
printEvent(color, tool, metadata.title)
} }
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
}
})
if (part.type === "text") { const result = await Session.chat({
if (part.text.includes("\n")) { sessionID: session.id,
UI.empty() providerID,
UI.println(part.text) modelID,
UI.empty() parts: [
return {
} type: "text",
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text) text: message,
} },
}) ],
await Session.chat({ })
sessionID: session.id,
providerID, if (isPiped) {
modelID, const match = result.parts.findLast((x) => x.type === "text")
parts: [ if (match) process.stdout.write(match.text)
{ }
type: "text", UI.empty()
text: message, })
},
],
})
UI.empty()
},
)
}, },
}) })
-18
View File
@@ -1,18 +0,0 @@
import { App } from "../../app/app"
import { LSP } from "../../lsp"
import { cmd } from "./cmd"
export const ScrapCommand = cmd({
command: "scrap <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await App.provide(
{ cwd: process.cwd() },
async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
},
)
},
})
+50
View File
@@ -0,0 +1,50 @@
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { Share } from "../../share/share"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
export const ServeCommand = cmd({
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 bootstrap({ 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()
})
},
})
+114
View File
@@ -0,0 +1,114 @@
import { Global } from "../../global"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { cmd } from "./cmd"
import path from "path"
import fs from "fs/promises"
import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
export const TuiCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
describe: "path to start opencode in",
}),
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
const result = await bootstrap({ cwd }, async (app) => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
const server = Server.listen({
port: 0,
hostname: "127.0.0.1",
})
let cmd = ["go", "run", "./main.go"]
let cwd = Bun.fileURLToPath(
new URL("../../../../tui/cmd/opencode", import.meta.url),
)
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
let binaryName = blob.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
}
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
;(async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest().catch(() => {})
if (!latest) return
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => {})
})()
await proc.exited
server.stop()
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
UI.println(UI.logo(" "))
const result = await Bun.spawn({
cmd: [process.execPath, "auth", "login"],
cwd: process.cwd(),
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
}).exited
if (result !== 0) return
UI.empty()
}
}
},
})
+3
View File
@@ -1,5 +1,6 @@
import { Config } from "../config/config" import { Config } from "../config/config"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { UI } from "./ui"
export function FormatError(input: unknown) { export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input)) if (MCP.Failed.isInstance(input))
@@ -13,4 +14,6 @@ export function FormatError(input: unknown) {
(issue) => "↳ " + issue.message + " " + issue.path.join("."), (issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []), ) ?? []),
].join("\n") ].join("\n")
if (UI.CancelledError.isInstance(input)) return ""
} }
+3 -2
View File
@@ -1,4 +1,5 @@
import { z } from "zod" import { z } from "zod"
import { EOL } from "os"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
export namespace UI { export namespace UI {
@@ -29,7 +30,7 @@ export namespace UI {
export function println(...message: string[]) { export function println(...message: string[]) {
print(...message) print(...message)
Bun.stderr.write("\n") Bun.stderr.write(EOL)
} }
export function print(...message: string[]) { export function print(...message: string[]) {
@@ -52,7 +53,7 @@ export namespace UI {
result.push(row[0]) result.push(row[0])
result.push("\x1b[0m") result.push("\x1b[0m")
result.push(row[1]) result.push(row[1])
result.push("\n") result.push(EOL)
} }
return result.join("").trimEnd() return result.join("").trimEnd()
} }
+39 -4
View File
@@ -22,6 +22,7 @@ export namespace Config {
} }
} }
log.info("loaded", result) log.info("loaded", result)
return result return result
}) })
@@ -36,20 +37,28 @@ export namespace Config {
.record(z.string(), z.string()) .record(z.string(), z.string())
.optional() .optional()
.describe("Environment variables to set when running the MCP server"), .describe("Environment variables to set when running the MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
}) })
.strict() .strict()
.openapi({ .openapi({
ref: "Config.McpLocal", ref: "McpLocalConfig",
}) })
export const McpRemote = z export const McpRemote = z
.object({ .object({
type: z.literal("remote").describe("Type of MCP server connection"), type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"), url: z.string().describe("URL of the remote MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
}) })
.strict() .strict()
.openapi({ .openapi({
ref: "Config.McpRemote", ref: "McpRemoteConfig",
}) })
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
@@ -123,7 +132,7 @@ export namespace Config {
}) })
.strict() .strict()
.openapi({ .openapi({
ref: "Config.Keybinds", ref: "KeybindsConfig",
}) })
export const Info = z export const Info = z
.object({ .object({
@@ -167,10 +176,36 @@ export namespace Config {
.record(z.string(), Mcp) .record(z.string(), Mcp)
.optional() .optional()
.describe("MCP (Model Context Protocol) server configurations"), .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() .strict()
.openapi({ .openapi({
ref: "Config.Info", ref: "Config",
}) })
export type Info = z.output<typeof Info> export type Info = z.output<typeof Info>
+54
View File
@@ -0,0 +1,54 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { Session } from "../session"
import { Log } from "../util/log"
import { Config } from "./config"
import path from "path"
export namespace ConfigHooks {
const log = Log.create({ service: "config.hooks" })
export function init() {
log.info("init")
const app = App.info()
Bus.subscribe(File.Event.Edited, async (payload) => {
const cfg = await Config.get()
const ext = path.extname(payload.properties.file)
for (const item of cfg.experimental?.hook?.file_edited?.[ext] ?? []) {
log.info("file_edited", {
file: payload.properties.file,
command: item.command,
})
Bun.spawn({
cmd: item.command.map((x) =>
x.replace("$FILE", payload.properties.file),
),
env: item.environment,
cwd: app.path.cwd,
stdout: "ignore",
stderr: "ignore",
})
}
})
Bus.subscribe(Session.Event.Idle, async () => {
const cfg = await Config.get()
if (cfg.experimental?.hook?.session_completed) {
for (const item of cfg.experimental.hook.session_completed) {
log.info("session_completed", {
command: item.command,
})
Bun.spawn({
cmd: item.command,
cwd: App.info().path.cwd,
env: item.environment,
stdout: "ignore",
stderr: "ignore",
})
}
}
})
}
}
-132
View File
@@ -1,132 +0,0 @@
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import { z } from "zod"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Fzf } from "./fzf"
export namespace Ripgrep {
const PLATFORM = {
darwin: { platform: "apple-darwin", extension: "tar.gz" },
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
win32: { platform: "pc-windows-msvc", extension: "zip" },
} as const
export const ExtractionFailedError = NamedError.create(
"RipgrepExtractionFailedError",
z.object({
filepath: z.string(),
stderr: z.string(),
}),
)
export const UnsupportedPlatformError = NamedError.create(
"RipgrepUnsupportedPlatformError",
z.object({
platform: z.string(),
}),
)
export const DownloadFailedError = NamedError.create(
"RipgrepDownloadFailedError",
z.object({
url: z.string(),
status: z.number(),
}),
)
const state = lazy(async () => {
let filepath = Bun.which("rg")
if (filepath) return { filepath }
filepath = path.join(
Global.Path.bin,
"rg" + (process.platform === "win32" ? ".exe" : ""),
)
const file = Bun.file(filepath)
if (!(await file.exists())) {
const archMap = { x64: "x86_64", arm64: "aarch64" } as const
const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
const version = "14.1.1"
const filename = `ripgrep-${version}-${arch}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
await Bun.write(archivePath, buffer)
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
if (process.platform === "darwin") args.push("--include=*/rg")
if (process.platform === "linux") args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "pipe",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath: archivePath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
await fs.unlink(archivePath)
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
}
return {
filepath,
}
})
export async function filepath() {
const { filepath } = await state()
return filepath
}
export async function files(input: {
cwd: string
query?: string
glob?: string
limit?: number
}) {
const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
if (input.query)
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
return result.split("\n").filter(Boolean)
}
}
@@ -5,7 +5,6 @@ import { z } from "zod"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { Log } from "../util/log" import { Log } from "../util/log"
import { $ } from "bun"
export namespace Fzf { export namespace Fzf {
const log = Log.create({ service: "fzf" }) const log = Log.create({ service: "fzf" })
@@ -115,24 +114,4 @@ export namespace Fzf {
const { filepath } = await state() const { filepath } = await state()
return filepath return filepath
} }
export async function search(input: {
cwd: string
query: string
limit?: number
}) {
const results = await $`${await filepath()} --filter=${input.query}`
.quiet()
.throws(false)
.cwd(input.cwd)
.text()
const split = results
.trim()
.split("\n")
.filter((line) => line.length > 0)
log.info("results", {
count: split.length,
})
return split
}
} }
+128
View File
@@ -0,0 +1,128 @@
import { z } from "zod"
import { Bus } from "../bus"
import { $ } from "bun"
import { createPatch } from "diff"
import path from "path"
import * as git from "isomorphic-git"
import { App } from "../app/app"
import fs from "fs"
import { Log } from "../util/log"
export namespace File {
const log = Log.create({ service: "file" })
export const Event = {
Edited: Bus.event(
"file.edited",
z.object({
file: z.string(),
}),
),
}
export async function status() {
const app = App.info()
if (!app.git) return []
const diffOutput = await $`git diff --numstat HEAD`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
const changedFiles = []
if (diffOutput.trim()) {
const lines = diffOutput.trim().split("\n")
for (const line of lines) {
const [added, removed, filepath] = line.split("\t")
changedFiles.push({
file: filepath,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = await $`git ls-files --others --exclude-standard`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Bun.file(
path.join(app.path.root, filepath),
).text()
const lines = content.split("\n").length
changedFiles.push({
file: filepath,
added: lines,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
// Get deleted files
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
.cwd(app.path.cwd)
.quiet()
.nothrow()
.text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
for (const filepath of deletedFiles) {
changedFiles.push({
file: filepath,
added: 0,
removed: 0, // Could get original line count but would require another git command
status: "deleted",
})
}
}
return changedFiles.map((x) => ({
...x,
file: path.relative(app.path.cwd, path.join(app.path.root, x.file)),
}))
}
export async function read(file: string) {
using _ = log.time("read", { file })
const app = App.info()
const full = path.join(app.path.cwd, file)
const content = await Bun.file(full)
.text()
.catch(() => "")
.then((x) => x.trim())
if (app.git) {
const rel = path.relative(app.path.root, full)
const diff = await git.status({
fs,
dir: app.path.root,
filepath: rel,
})
if (diff !== "unmodified") {
const original = await $`git show HEAD:${rel}`
.cwd(app.path.root)
.quiet()
.nothrow()
.text()
const patch = createPatch(file, original, content, "old", "new", {
context: Infinity,
})
return { type: "patch", content: patch }
}
}
return { type: "raw", content }
}
}
+353
View File
@@ -0,0 +1,353 @@
// Ripgrep utility functions
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import { z } from "zod"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Fzf } from "./fzf"
export namespace Ripgrep {
const Stats = z.object({
elapsed: z.object({
secs: z.number(),
nanos: z.number(),
human: z.string(),
}),
searches: z.number(),
searches_with_match: z.number(),
bytes_searched: z.number(),
bytes_printed: z.number(),
matched_lines: z.number(),
matches: z.number(),
})
const Begin = z.object({
type: z.literal("begin"),
data: z.object({
path: z.object({
text: z.string(),
}),
}),
})
export const Match = z.object({
type: z.literal("match"),
data: z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
}),
),
}),
})
const End = z.object({
type: z.literal("end"),
data: z.object({
path: z.object({
text: z.string(),
}),
binary_offset: z.number().nullable(),
stats: Stats,
}),
})
const Summary = z.object({
type: z.literal("summary"),
data: z.object({
elapsed_total: z.object({
human: z.string(),
nanos: z.number(),
secs: z.number(),
}),
stats: Stats,
}),
})
const Result = z.union([Begin, Match, End, Summary])
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": {
platform: "aarch64-unknown-linux-gnu",
extension: "tar.gz",
},
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
export const ExtractionFailedError = NamedError.create(
"RipgrepExtractionFailedError",
z.object({
filepath: z.string(),
stderr: z.string(),
}),
)
export const UnsupportedPlatformError = NamedError.create(
"RipgrepUnsupportedPlatformError",
z.object({
platform: z.string(),
}),
)
export const DownloadFailedError = NamedError.create(
"RipgrepDownloadFailedError",
z.object({
url: z.string(),
status: z.number(),
}),
)
const state = lazy(async () => {
let filepath = Bun.which("rg")
if (filepath) return { filepath }
filepath = path.join(
Global.Path.bin,
"rg" + (process.platform === "win32" ? ".exe" : ""),
)
const file = Bun.file(filepath)
if (!(await file.exists())) {
const platformKey =
`${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
const version = "14.1.1"
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
await Bun.write(archivePath, buffer)
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "pipe",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath: archivePath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
await fs.unlink(archivePath)
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
}
return {
filepath,
}
})
export async function filepath() {
const { filepath } = await state()
return filepath
}
export async function files(input: {
cwd: string
query?: string
glob?: string
limit?: number
}) {
const commands = [
`${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)
}
export async function tree(input: { cwd: string; limit?: number }) {
const files = await Ripgrep.files({ cwd: input.cwd })
interface Node {
path: string[]
children: Node[]
}
function getPath(node: Node, parts: string[], create: boolean) {
if (parts.length === 0) return node
let current = node
for (const part of parts) {
let existing = current.children.find((x) => x.path.at(-1) === part)
if (!existing) {
if (!create) return
existing = {
path: current.path.concat(part),
children: [],
}
current.children.push(existing)
}
current = existing
}
return current
}
const root: Node = {
path: [],
children: [],
}
for (const file of files) {
const parts = file.split(path.sep)
getPath(root, parts, true)
}
function sort(node: Node) {
node.children.sort((a, b) => {
if (!a.children.length && b.children.length) return 1
if (!b.children.length && a.children.length) return -1
return a.path.at(-1)!.localeCompare(b.path.at(-1)!)
})
for (const child of node.children) {
sort(child)
}
}
sort(root)
let current = [root]
const result: Node = {
path: [],
children: [],
}
let processed = 0
const limit = input.limit ?? 50
while (current.length > 0) {
const next = []
for (const node of current) {
if (node.children.length) next.push(...node.children)
}
const max = Math.max(...current.map((x) => x.children.length))
for (let i = 0; i < max && processed < limit; i++) {
for (const node of current) {
const child = node.children[i]
if (!child) continue
getPath(result, child.path, true)
processed++
if (processed >= limit) break
}
}
if (processed >= limit) {
for (const node of [...current, ...next]) {
const compare = getPath(result, node.path, false)
if (!compare) continue
if (compare?.children.length !== node.children.length) {
const diff = node.children.length - compare.children.length
compare.children.push({
path: compare.path.concat(`[${diff} truncated]`),
children: [],
})
}
}
break
}
current = next
}
const lines: string[] = []
function render(node: Node, depth: number) {
const indent = "\t".repeat(depth)
lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : ""))
for (const child of node.children) {
render(child, depth + 1)
}
}
result.children.map((x) => render(x, 0))
return lines.join("\n")
}
export async function search(input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
}) {
const args = [
`${await filepath()}`,
"--json",
"--hidden",
"--glob='!.git/*'",
]
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push(input.pattern)
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
return []
}
const lines = result.text().trim().split("\n").filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((r) => r.type === "match")
.map((r) => r.data)
}
}
@@ -1,6 +1,6 @@
import { App } from "../../app/app" import { App } from "../app/app"
export namespace FileTimes { export namespace FileTime {
export const state = App.state("tool.filetimes", () => { export const state = App.state("tool.filetimes", () => {
const read: { const read: {
[sessionID: string]: { [sessionID: string]: {
+52
View File
@@ -0,0 +1,52 @@
import { z } from "zod"
import { Bus } from "../bus"
import fs from "fs"
import { App } from "../app/app"
import { Log } from "../util/log"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
export const Event = {
Updated: Bus.event(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("rename"), z.literal("change")]),
}),
),
}
export function init() {
App.state(
"file.watcher",
() => {
const app = App.use()
try {
const watcher = fs.watch(
app.info.path.cwd,
{ recursive: true },
(event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
})
},
)
return { watcher }
} catch {
return {}
}
},
async (state) => {
state.watcher?.close()
},
)()
}
}
+160
View File
@@ -0,0 +1,160 @@
import { App } from "../app/app"
import { BunProc } from "../bun"
export interface Info {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
}
export const gofmt: Info = {
name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return Bun.which("gofmt") !== null
},
}
export const mix: Info = {
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return Bun.which("mix") !== null
},
}
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
],
async enabled() {
// this is more complicated because we only want to use prettier if it's
// being used with the current project
try {
const proc = Bun.spawn({
cmd: [BunProc.which(), "run", "prettier", "--version"],
cwd: App.info().path.cwd,
env: {
BUN_BE_BUN: "1",
},
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
}
},
}
export const zig: Info = {
name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return Bun.which("zig") !== null
},
}
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [
".c",
".cc",
".cpp",
".cxx",
".c++",
".h",
".hh",
".hpp",
".hxx",
".h++",
".ino",
".C",
".H",
],
async enabled() {
return Bun.which("clang-format") !== null
},
}
export const ktlint: Info = {
name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return Bun.which("ktlint") !== null
},
}
export const ruff: Info = {
name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
return Bun.which("ruff") !== null
},
}
export const rubocop: Info = {
name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("rubocop") !== null
},
}
export const standardrb: Info = {
name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("standardrb") !== null
},
}
export const htmlbeautifier: Info = {
name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return Bun.which("htmlbeautifier") !== null
},
}
+65
View File
@@ -0,0 +1,65 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
import * as Formatter from "./formatter"
export namespace Format {
const log = Log.create({ service: "format" })
const state = App.state("format", () => {
const enabled: Record<string, boolean> = {}
return {
enabled,
}
})
async function isEnabled(item: Formatter.Info) {
const s = state()
let status = s.enabled[item.name]
if (status === undefined) {
status = await item.enabled()
s.enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const result = []
for (const item of Object.values(Formatter)) {
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
result.push(item)
}
return result
}
export function init() {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
const proc = Bun.spawn({
cmd: item.command.map((x) => x.replace("$FILE", file)),
cwd: App.info().path.cwd,
env: item.environment,
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
...item.environment,
})
}
})
}
}
+37 -97
View File
@@ -1,32 +1,37 @@
import "zod-openapi/extend" import "zod-openapi/extend"
import { App } from "./app/app"
import { Server } from "./server/server"
import fs from "fs/promises"
import path from "path"
import { Share } from "./share/share"
import { Global } from "./global"
import yargs from "yargs" import yargs from "yargs"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run" import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate" import { GenerateCommand } from "./cli/cmd/generate"
import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log" import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth" import { AuthCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade" import { UpgradeCommand } from "./cli/cmd/upgrade"
import { Provider } from "./provider/provider" import { ModelsCommand } from "./cli/cmd/models"
import { UI } from "./cli/ui" import { UI } from "./cli/ui"
import { Installation } from "./installation" import { Installation } from "./installation"
import { Bus } from "./bus"
import { Config } from "./config/config"
import { NamedError } from "./util/error" import { NamedError } from "./util/error"
import { FormatError } from "./cli/error" import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { TuiCommand } from "./cli/cmd/tui"
import { DebugCommand } from "./cli/cmd/debug"
const cancel = new AbortController() 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)) const cli = yargs(hideBin(process.argv))
.scriptName("opencode") .scriptName("opencode")
.help("help", "show help") .help("help", "show help")
.alias("help", "h")
.version("version", "show version number", Installation.VERSION) .version("version", "show version number", Installation.VERSION)
.alias("version", "v") .alias("version", "v")
.option("print-logs", { .option("print-logs", {
@@ -41,93 +46,14 @@ const cli = yargs(hideBin(process.argv))
}) })
}) })
.usage("\n" + UI.logo()) .usage("\n" + UI.logo())
.command({ .command(TuiCommand)
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
describe: "path to start opencode in",
}),
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
process.chdir(cwd)
const result = await App.provide({ cwd }, async (app) => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
await Share.init()
const server = Server.listen()
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 proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
signal: cancel.signal,
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
; (async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest()
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => { })
})()
await proc.exited
server.stop()
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
await AuthLoginCommand.handler(args)
}
}
},
})
.command(RunCommand) .command(RunCommand)
.command(GenerateCommand) .command(GenerateCommand)
.command(ScrapCommand) .command(DebugCommand)
.command(AuthCommand) .command(AuthCommand)
.command(UpgradeCommand) .command(UpgradeCommand)
.command(ServeCommand)
.command(ModelsCommand)
.fail((msg) => { .fail((msg) => {
if ( if (
msg.startsWith("Unknown argument") || msg.startsWith("Unknown argument") ||
@@ -141,13 +67,14 @@ const cli = yargs(hideBin(process.argv))
try { try {
await cli.parse() await cli.parse()
} catch (e) { } catch (e) {
const data: Record<string, any> = {} let data: Record<string, any> = {}
if (e instanceof NamedError) { if (e instanceof NamedError) {
const obj = e.toObject() const obj = e.toObject()
Object.assign(data, { Object.assign(data, {
...obj.data, ...obj.data,
}) })
} }
if (e instanceof Error) { if (e instanceof Error) {
Object.assign(data, { Object.assign(data, {
name: e.name, name: e.name,
@@ -155,13 +82,26 @@ try {
cause: e.cause?.toString(), cause: e.cause?.toString(),
}) })
} }
if (e instanceof ResolveMessage) {
Object.assign(data, {
name: e.name,
message: e.message,
code: e.code,
specifier: e.specifier,
referrer: e.referrer,
position: e.position,
importKind: e.importKind,
})
}
Log.Default.error("fatal", data) Log.Default.error("fatal", data)
const formatted = FormatError(e) const formatted = FormatError(e)
if (formatted) UI.error(formatted) if (formatted) UI.error(formatted)
if (!formatted) if (formatted === undefined)
UI.error( UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details", "Unexpected error, check log file at " + Log.file() + " for more details",
) )
process.exitCode = 1
} }
cancel.abort() cancel.abort()
+30 -40
View File
@@ -12,6 +12,7 @@ import { Bus } from "../bus"
import z from "zod" import z from "zod"
import type { LSPServer } from "./server" import type { LSPServer } from "./server"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { withTimeout } from "../util/timeout"
export namespace LSPClient { export namespace LSPClient {
const log = Log.create({ service: "lsp.client" }) const log = Log.create({ service: "lsp.client" })
@@ -52,7 +53,9 @@ export namespace LSPClient {
log.info("textDocument/publishDiagnostics", { log.info("textDocument/publishDiagnostics", {
path, path,
}) })
const exists = diagnostics.has(path)
diagnostics.set(path, params.diagnostics) diagnostics.set(path, params.diagnostics)
if (!exists && serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID }) Bus.publish(Event.Diagnostics, { path, serverID })
}) })
connection.onRequest("workspace/configuration", async () => { connection.onRequest("workspace/configuration", async () => {
@@ -61,7 +64,7 @@ export namespace LSPClient {
connection.listen() connection.listen()
log.info("sending initialize", { id: serverID }) log.info("sending initialize", { id: serverID })
await Promise.race([ await withTimeout(
connection.sendRequest("initialize", { connection.sendRequest("initialize", {
processId: server.process.pid, processId: server.process.pid,
workspaceFolders: [ workspaceFolders: [
@@ -88,12 +91,10 @@ export namespace LSPClient {
}, },
}, },
}), }),
new Promise((_, reject) => { 5_000,
setTimeout(() => { ).catch(() => {
reject(new InitializeError({ serverID })) throw new InitializeError({ serverID })
}, 5_000) })
}),
])
await connection.sendNotification("initialized", {}) await connection.sendNotification("initialized", {})
log.info("initialized") log.info("initialized")
@@ -116,36 +117,28 @@ export namespace LSPClient {
const file = Bun.file(input.path) const file = Bun.file(input.path)
const text = await file.text() const text = await file.text()
const version = files[input.path] const version = files[input.path]
if (version === undefined) { if (version !== undefined) {
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path) diagnostics.delete(input.path)
const extension = path.extname(input.path) await connection.sendNotification("textDocument/didClose", {
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
textDocument: { textDocument: {
uri: `file://` + input.path, uri: `file://` + input.path,
languageId,
version: 0,
text,
}, },
}) })
files[input.path] = 0
return
} }
log.info("textDocument/didOpen", input)
log.info("textDocument/didChange", input)
diagnostics.delete(input.path) diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didChange", { const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
textDocument: { textDocument: {
uri: `file://` + input.path, uri: `file://` + input.path,
version: ++files[input.path], languageId,
version: 0,
text,
}, },
contentChanges: [
{
text,
},
],
}) })
files[input.path] = 0
return
}, },
}, },
get diagnostics() { get diagnostics() {
@@ -157,35 +150,32 @@ export namespace LSPClient {
: path.resolve(app.path.cwd, input.path) : path.resolve(app.path.cwd, input.path)
log.info("waiting for diagnostics", input) log.info("waiting for diagnostics", input)
let unsub: () => void let unsub: () => void
let timeout: NodeJS.Timeout return await withTimeout(
return await Promise.race([ new Promise<void>((resolve) => {
new Promise<void>(async (resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => { unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if ( if (
event.properties.path === input.path && event.properties.path === input.path &&
event.properties.serverID === result.serverID event.properties.serverID === result.serverID
) { ) {
log.info("got diagnostics", input) log.info("got diagnostics", input)
clearTimeout(timeout)
unsub?.() unsub?.()
resolve() resolve()
} }
}) })
}), }),
new Promise<void>((resolve) => { 3000,
timeout = setTimeout(() => { )
log.info("timed out refreshing diagnostics", input) .catch(() => {})
unsub?.() .finally(() => {
resolve() unsub?.()
}, 5000) })
}),
])
}, },
async shutdown() { async shutdown() {
log.info("shutting down") log.info("shutting down", { serverID })
connection.end() connection.end()
connection.dispose() connection.dispose()
server.process.kill("SIGKILL") server.process.kill("SIGTERM")
log.info("shutdown", { serverID })
}, },
} }
+67 -30
View File
@@ -3,19 +3,60 @@ import { Log } from "../util/log"
import { LSPClient } from "./client" import { LSPClient } from "./client"
import path from "path" import path from "path"
import { LSPServer } from "./server" import { LSPServer } from "./server"
import { Ripgrep } from "../file/ripgrep"
import { z } from "zod"
export namespace LSP { export namespace LSP {
const log = Log.create({ service: "lsp" }) const log = Log.create({ service: "lsp" })
export const Symbol = z
.object({
name: z.string(),
kind: z.number(),
location: z.object({
uri: z.string(),
range: z.object({
start: z.object({
line: z.number(),
character: z.number(),
}),
end: z.object({
line: z.number(),
character: z.number(),
}),
}),
}),
})
.openapi({
ref: "LSP.Symbol",
})
export type Symbol = z.infer<typeof Symbol>
const state = App.state( const state = App.state(
"lsp", "lsp",
async () => { async (app) => {
log.info("initializing") log.info("initializing")
const clients = new Map<string, LSPClient.Info>() const clients = new Map<string, LSPClient.Info>()
const skip = new Set<string>() for (const server of Object.values(LSPServer)) {
for (const extension of server.extensions) {
const [file] = await Ripgrep.files({
cwd: app.path.cwd,
glob: "*" + extension,
})
if (!file) continue
const handle = await server.spawn(App.info())
if (!handle) break
const client = await LSPClient.create(server.id, handle).catch(
() => {},
)
if (!client) break
clients.set(server.id, client)
break
}
}
log.info("initialized")
return { return {
clients, clients,
skip,
} }
}, },
async (state) => { async (state) => {
@@ -25,35 +66,23 @@ export namespace LSP {
}, },
) )
export async function init() {
return state()
}
export async function touchFile(input: string, waitForDiagnostics?: boolean) { export async function touchFile(input: string, waitForDiagnostics?: boolean) {
const extension = path.parse(input).ext const extension = path.parse(input).ext
const s = await state() const matches = Object.values(LSPServer)
const matches = LSPServer.All.filter((x) => .filter((x) => x.extensions.includes(extension))
x.extensions.includes(extension), .map((x) => x.id)
) await run(async (client) => {
for (const match of matches) { if (!matches.includes(client.serverID)) return
if (s.skip.has(match.id)) continue const wait = waitForDiagnostics
const existing = s.clients.get(match.id) ? client.waitForDiagnostics({ path: input })
if (existing) continue : Promise.resolve()
const handle = await match.spawn(App.info()) await client.notify.open({ path: input })
if (!handle) { return wait
s.skip.add(match.id) })
continue
}
const client = await LSPClient.create(match.id, handle).catch(() => {})
if (!client) {
s.skip.add(match.id)
continue
}
s.clients.set(match.id, client)
}
if (waitForDiagnostics) {
await run(async (client) => {
const wait = client.waitForDiagnostics({ path: input })
await client.notify.open({ path: input })
return wait
})
}
} }
export async function diagnostics() { export async function diagnostics() {
@@ -86,6 +115,14 @@ export namespace LSP {
}) })
} }
export async function workspaceSymbol(query: string) {
return run((client) =>
client.connection.sendRequest("workspace/symbol", {
query,
}),
).then((result) => result.flat() as LSP.Symbol[])
}
async function run<T>( async function run<T>(
input: (client: LSPClient.Info) => Promise<T>, input: (client: LSPClient.Info) => Promise<T>,
): Promise<T[]> { ): Promise<T[]> {
+8
View File
@@ -63,6 +63,14 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".cshtml": "razor", ".cshtml": "razor",
".razor": "razor", ".razor": "razor",
".rb": "ruby", ".rb": "ruby",
".rake": "ruby",
".gemspec": "ruby",
".ru": "ruby",
".erb": "erb",
".html.erb": "erb",
".js.erb": "erb",
".css.erb": "erb",
".json.erb": "erb",
".rs": "rust", ".rs": "rust",
".scss": "scss", ".scss": "scss",
".sass": "sass", ".sass": "sass",
+118 -68
View File
@@ -19,78 +19,128 @@ export namespace LSPServer {
spawn(app: App.Info): Promise<Handle | undefined> spawn(app: App.Info): Promise<Handle | undefined>
} }
export const All: Info[] = [ export const Typescript: Info = {
{ id: "typescript",
id: "typescript", extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
extensions: [ async spawn(app) {
".ts", const tsserver = await Bun.resolve(
".tsx", "typescript/lib/tsserver.js",
".js", app.path.cwd,
".jsx", ).catch(() => {})
".mjs", if (!tsserver) return
".cjs", const proc = spawn(
".mts", BunProc.which(),
".cts", ["x", "typescript-language-server", "--stdio"],
], {
async spawn(app) { env: {
const tsserver = await Bun.resolve( ...process.env,
"typescript/lib/tsserver.js", BUN_BE_BUN: "1",
app.path.cwd,
).catch(() => {})
if (!tsserver) return
const proc = spawn(
BunProc.which(),
["x", "typescript-language-server", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
}, },
) },
return { )
process: proc, return {
initialization: { process: proc,
tsserver: { initialization: {
path: tsserver, tsserver: {
}, path: tsserver,
}, },
} },
}, }
}, },
{ }
id: "golang",
extensions: [".go"], export const Gopls: Info = {
async spawn() { id: "golang",
let bin = Bun.which("gopls", { extensions: [".go"],
PATH: process.env["PATH"] + ":" + Global.Path.bin, async spawn() {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
log.info("installing gopls")
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}) })
if (!bin) { const exit = await proc.exited
log.info("installing gopls") if (exit !== 0) {
const proc = Bun.spawn({ log.error("Failed to install gopls")
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"], return
env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
log.error("Failed to install gopls")
return
}
bin = path.join(
Global.Path.bin,
"gopls" + (process.platform === "win32" ? ".exe" : ""),
)
log.info(`installed gopls`, {
bin,
})
} }
return { bin = path.join(
process: spawn(bin!), Global.Path.bin,
} "gopls" + (process.platform === "win32" ? ".exe" : ""),
}, )
log.info(`installed gopls`, {
bin,
})
}
return {
process: spawn(bin!),
}
}, },
] }
export const RubyLsp: Info = {
id: "ruby-lsp",
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn() {
let bin = Bun.which("ruby-lsp", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
const ruby = Bun.which("ruby")
const gem = Bun.which("gem")
if (!ruby || !gem) {
log.info("Ruby not found, please install Ruby first")
return
}
log.info("installing ruby-lsp")
const proc = Bun.spawn({
cmd: ["gem", "install", "ruby-lsp", "--bindir", Global.Path.bin],
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
log.error("Failed to install ruby-lsp")
return
}
bin = path.join(
Global.Path.bin,
"ruby-lsp" + (process.platform === "win32" ? ".exe" : ""),
)
log.info(`installed ruby-lsp`, {
bin,
})
}
return {
process: spawn(bin!, ["--stdio"]),
}
},
}
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
async spawn() {
const proc = spawn(
BunProc.which(),
["x", "pyright-langserver", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
},
)
return {
process: proc,
}
},
}
} }
+4
View File
@@ -26,6 +26,10 @@ export namespace MCP {
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>> [name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
} = {} } = {}
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) { for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
continue
}
log.info("found", { key, type: mcp.type }) log.info("found", { key, type: mcp.type })
if (mcp.type === "remote") { if (mcp.type === "remote") {
const client = await experimental_createMCPClient({ const client = await experimental_createMCPClient({
+4 -3
View File
@@ -10,7 +10,9 @@ export namespace ModelsDev {
export const Model = z export const Model = z
.object({ .object({
id: z.string(),
name: z.string(), name: z.string(),
release_date: z.string(),
attachment: z.boolean(), attachment: z.boolean(),
reasoning: z.boolean(), reasoning: z.boolean(),
temperature: z.boolean(), temperature: z.boolean(),
@@ -25,11 +27,10 @@ export namespace ModelsDev {
context: z.number(), context: z.number(),
output: z.number(), output: z.number(),
}), }),
id: z.string(),
options: z.record(z.any()), options: z.record(z.any()),
}) })
.openapi({ .openapi({
ref: "Model.Info", ref: "Model",
}) })
export type Model = z.infer<typeof Model> export type Model = z.infer<typeof Model>
@@ -43,7 +44,7 @@ export namespace ModelsDev {
models: z.record(Model), models: z.record(Model),
}) })
.openapi({ .openapi({
ref: "Provider.Info", ref: "Provider",
}) })
export type Provider = z.infer<typeof Provider> export type Provider = z.infer<typeof Provider>
+110 -26
View File
@@ -11,8 +11,6 @@ import { WebFetchTool } from "../tool/webfetch"
import { GlobTool } from "../tool/glob" import { GlobTool } from "../tool/glob"
import { GrepTool } from "../tool/grep" import { GrepTool } from "../tool/grep"
import { ListTool } from "../tool/ls" import { ListTool } from "../tool/ls"
import { LspDiagnosticTool } from "../tool/lsp-diagnostics"
import { LspHoverTool } from "../tool/lsp-hover"
import { PatchTool } from "../tool/patch" import { PatchTool } from "../tool/patch"
import { ReadTool } from "../tool/read" import { ReadTool } from "../tool/read"
import type { Tool } from "../tool/tool" import type { Tool } from "../tool/tool"
@@ -23,25 +21,26 @@ import { AuthCopilot } from "../auth/copilot"
import { ModelsDev } from "./models" import { ModelsDev } from "./models"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { Auth } from "../auth" import { Auth } from "../auth"
import { TaskTool } from "../tool/task" // import { TaskTool } from "../tool/task"
export namespace Provider { export namespace Provider {
const log = Log.create({ service: "provider" }) const log = Log.create({ service: "provider" })
type CustomLoader = (provider: ModelsDev.Provider) => Promise< type CustomLoader = (
| { provider: ModelsDev.Provider,
getModel?: (sdk: any, modelID: string) => Promise<any> api?: string,
options: Record<string, any> ) => Promise<{
} autoload: boolean
| false getModel?: (sdk: any, modelID: string) => Promise<any>
> options?: Record<string, any>
}>
type Source = "env" | "config" | "custom" | "api" type Source = "env" | "config" | "custom" | "api"
const CUSTOM_LOADERS: Record<string, CustomLoader> = { const CUSTOM_LOADERS: Record<string, CustomLoader> = {
async anthropic(provider) { async anthropic(provider) {
const access = await AuthAnthropic.access() const access = await AuthAnthropic.access()
if (!access) return false if (!access) return { autoload: false }
for (const model of Object.values(provider.models)) { for (const model of Object.values(provider.models)) {
model.cost = { model.cost = {
input: 0, input: 0,
@@ -49,6 +48,7 @@ export namespace Provider {
} }
} }
return { return {
autoload: true,
options: { options: {
apiKey: "", apiKey: "",
async fetch(input: any, init: any) { async fetch(input: any, init: any) {
@@ -69,9 +69,9 @@ export namespace Provider {
}, },
"github-copilot": async (provider) => { "github-copilot": async (provider) => {
const copilot = await AuthCopilot() const copilot = await AuthCopilot()
if (!copilot) return false if (!copilot) return { autoload: false }
let info = await Auth.get("github-copilot") let info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return false if (!info || info.type !== "oauth") return { autoload: false }
if (provider && provider.models) { if (provider && provider.models) {
for (const model of Object.values(provider.models)) { for (const model of Object.values(provider.models)) {
@@ -83,6 +83,7 @@ export namespace Provider {
} }
return { return {
autoload: true,
options: { options: {
apiKey: "", apiKey: "",
async fetch(input: any, init: any) { async fetch(input: any, init: any) {
@@ -98,11 +99,25 @@ export namespace Provider {
}) })
info.access = tokens.access info.access = tokens.access
} }
let isAgentCall = false
try {
const body =
typeof init.body === "string"
? JSON.parse(init.body)
: init.body
if (body?.messages) {
isAgentCall = body.messages.some(
(msg: any) =>
msg.role && ["tool", "assistant"].includes(msg.role),
)
}
} catch {}
const headers = { const headers = {
...init.headers, ...init.headers,
...copilot.HEADERS, ...copilot.HEADERS,
Authorization: `Bearer ${info.access}`, Authorization: `Bearer ${info.access}`,
"Openai-Intent": "conversation-edits", "Openai-Intent": "conversation-edits",
"X-Initiator": isAgentCall ? "agent" : "user",
} }
delete headers["x-api-key"] delete headers["x-api-key"]
return fetch(input, { return fetch(input, {
@@ -115,6 +130,7 @@ export namespace Provider {
}, },
openai: async () => { openai: async () => {
return { return {
autoload: false,
async getModel(sdk: any, modelID: string) { async getModel(sdk: any, modelID: string) {
return sdk.responses(modelID) return sdk.responses(modelID)
}, },
@@ -123,7 +139,7 @@ export namespace Provider {
}, },
"amazon-bedrock": async () => { "amazon-bedrock": async () => {
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"]) if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
return false return { autoload: false }
const region = process.env["AWS_REGION"] ?? "us-east-1" const region = process.env["AWS_REGION"] ?? "us-east-1"
@@ -131,19 +147,75 @@ export namespace Provider {
await BunProc.install("@aws-sdk/credential-providers") await BunProc.install("@aws-sdk/credential-providers")
) )
return { return {
autoload: true,
options: { options: {
region, region,
credentialProvider: fromNodeProviderChain(), credentialProvider: fromNodeProviderChain(),
}, },
async getModel(sdk: any, modelID: string) { async getModel(sdk: any, modelID: string) {
if (modelID.includes("claude")) { let regionPrefix = region.split("-")[0]
const prefix = region.split("-")[0]
modelID = `${prefix}.${modelID}` switch (regionPrefix) {
case "us": {
const modelRequiresPrefix = ["claude", "deepseek"].some((m) =>
modelID.includes(m),
)
if (modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
break
}
case "eu": {
const regionRequiresPrefix = [
"eu-west-1",
"eu-west-3",
"eu-north-1",
"eu-central-1",
"eu-south-1",
"eu-south-2",
].some((r) => region.includes(r))
const modelRequiresPrefix = [
"claude",
"nova-lite",
"nova-micro",
"llama3",
"pixtral",
].some((m) => modelID.includes(m))
if (regionRequiresPrefix && modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
break
}
case "ap": {
const modelRequiresPrefix = [
"claude",
"nova-lite",
"nova-micro",
"nova-pro",
].some((m) => modelID.includes(m))
if (modelRequiresPrefix) {
regionPrefix = "apac"
modelID = `${regionPrefix}.${modelID}`
}
break
}
} }
return sdk.languageModel(modelID) return sdk.languageModel(modelID)
}, },
} }
}, },
openrouter: async () => {
return {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
},
}
},
} }
const state = App.state("provider", async () => { const state = App.state("provider", async () => {
@@ -181,6 +253,7 @@ export namespace Provider {
source, source,
info, info,
options, options,
getModel,
} }
return return
} }
@@ -198,6 +271,7 @@ export namespace Provider {
npm: provider.npm ?? existing?.npm, npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID, name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [], env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
models: existing?.models ?? {}, models: existing?.models ?? {},
} }
@@ -206,6 +280,7 @@ export namespace Provider {
const parsedModel: ModelsDev.Model = { const parsedModel: ModelsDev.Model = {
id: modelID, id: modelID,
name: model.name ?? existing?.name ?? modelID, name: model.name ?? existing?.name ?? modelID,
release_date: model.release_date ?? existing?.release_date,
attachment: model.attachment ?? existing?.attachment ?? false, attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false, reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false, temperature: model.temperature ?? existing?.temperature ?? false,
@@ -239,9 +314,14 @@ export namespace Provider {
// load env // load env
for (const [providerID, provider] of Object.entries(database)) { for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue if (disabled.has(providerID)) continue
if (provider.env.some((item) => process.env[item])) { const apiKey = provider.env.map((item) => process.env[item]).at(0)
mergeProvider(providerID, {}, "env") if (!apiKey) continue
} mergeProvider(
providerID,
// only include apiKey if there's only one potential option
provider.env.length === 1 ? { apiKey } : {},
"env",
)
} }
// load apikeys // load apikeys
@@ -256,8 +336,13 @@ export namespace Provider {
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) { for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue if (disabled.has(providerID)) continue
const result = await fn(database[providerID]) const result = await fn(database[providerID])
if (result) { if (result && (result.autoload || providers[providerID])) {
mergeProvider(providerID, result.options, "custom", result.getModel) mergeProvider(
providerID,
result.options ?? {},
"custom",
result.getModel,
)
} }
} }
@@ -393,16 +478,15 @@ export namespace Provider {
GlobTool, GlobTool,
GrepTool, GrepTool,
ListTool, ListTool,
LspDiagnosticTool, // LspDiagnosticTool,
LspHoverTool, // LspHoverTool,
PatchTool, PatchTool,
ReadTool, ReadTool,
EditTool,
// MultiEditTool, // MultiEditTool,
WriteTool, WriteTool,
TodoWriteTool, TodoWriteTool,
TaskTool,
TodoReadTool, TodoReadTool,
// TaskTool,
] ]
const TOOL_MAPPING: Record<string, Tool.Info[]> = { const TOOL_MAPPING: Record<string, Tool.Info[]> = {
@@ -20,6 +20,19 @@ export namespace ProviderTransform {
} }
} }
} }
if (providerID === "amazon-bedrock" || modelID.includes("anthropic")) {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerMetadata = {
...msg.providerMetadata,
bedrock: {
cachePoint: { type: "ephemeral" },
},
}
}
}
return msgs return msgs
} }
} }
+316 -227
View File
@@ -9,13 +9,13 @@ import { z } from "zod"
import { Message } from "../session/message" import { Message } from "../session/message"
import { Provider } from "../provider/provider" import { Provider } from "../provider/provider"
import { App } from "../app/app" import { App } from "../app/app"
import { Global } from "../global"
import { mapValues } from "remeda" import { mapValues } from "remeda"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models" import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Installation } from "../installation"
import { Config } from "../config/config" import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
const ERRORS = { const ERRORS = {
400: { 400: {
@@ -70,12 +70,12 @@ export namespace Server {
}) })
}) })
.get( .get(
"/openapi", "/doc",
openAPISpecs(app, { openAPISpecs(app, {
documentation: { documentation: {
info: { info: {
title: "opencode", title: "opencode",
version: "1.0.0", version: "0.0.3",
description: "opencode api", description: "opencode api",
}, },
openapi: "3.0.0", openapi: "3.0.0",
@@ -122,8 +122,8 @@ export namespace Server {
}) })
}, },
) )
.post( .get(
"/app_info", "/app",
describeRoute({ describeRoute({
description: "Get app info", description: "Get app info",
responses: { responses: {
@@ -142,26 +142,7 @@ export namespace Server {
}, },
) )
.post( .post(
"/config_get", "/app/init",
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({ describeRoute({
description: "Initialize the app", description: "Initialize the app",
responses: { responses: {
@@ -180,172 +161,27 @@ export namespace Server {
return c.json(true) return c.json(true)
}, },
) )
.post( .get(
"/session_initialize", "/config",
describeRoute({ describeRoute({
description: "Analyze the app and create an AGENTS.md file", description: "Get config info",
responses: { responses: {
200: { 200: {
description: "200", description: "Get config info",
content: { content: {
"application/json": { "application/json": {
schema: resolver(z.boolean()), schema: resolver(Config.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.initialize(body)
return c.json(true)
},
)
.post(
"/path_get",
describeRoute({
description: "Get paths",
responses: {
200: {
description: "200",
content: {
"application/json": {
schema: resolver(
z.object({
root: z.string(),
data: z.string(),
cwd: z.string(),
config: z.string(),
}),
),
}, },
}, },
}, },
}, },
}), }),
async (c) => { async (c) => {
const app = App.info() return c.json(await Config.get())
return c.json({
root: app.path.root,
data: app.path.data,
cwd: app.path.cwd,
config: Global.Path.data,
})
}, },
) )
.post( .get(
"/session_create", "/session",
describeRoute({
description: "Create a new session",
responses: {
...ERRORS,
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
async (c) => {
const session = await Session.create()
return c.json(session)
},
)
.post(
"/session_share",
describeRoute({
description: "Share the session",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.share(body.sessionID)
const session = await Session.get(body.sessionID)
return c.json(session)
},
)
.post(
"/session_unshare",
describeRoute({
description: "Unshare the session",
responses: {
200: {
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.unshare(body.sessionID)
const session = await Session.get(body.sessionID)
return c.json(session)
},
)
.post(
"/session_messages",
describeRoute({
description: "Get messages for a session",
responses: {
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const messages = await Session.messages(c.req.valid("json").sessionID)
return c.json(messages)
},
)
.post(
"/session_list",
describeRoute({ describeRoute({
description: "List all sessions", description: "List all sessions",
responses: { responses: {
@@ -365,33 +201,28 @@ export namespace Server {
}, },
) )
.post( .post(
"/session_abort", "/session",
describeRoute({ describeRoute({
description: "Abort a session", description: "Create a new session",
responses: { responses: {
...ERRORS,
200: { 200: {
description: "Aborted session", description: "Successfully created session",
content: { content: {
"application/json": { "application/json": {
schema: resolver(z.boolean()), schema: resolver(Session.Info),
}, },
}, },
}, },
}, },
}), }),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => { async (c) => {
const body = c.req.valid("json") const session = await Session.create()
return c.json(Session.abort(body.sessionID)) return c.json(session)
}, },
) )
.post( .delete(
"/session_delete", "/session/:id",
describeRoute({ describeRoute({
description: "Delete a session and all its data", description: "Delete a session and all its data",
responses: { responses: {
@@ -406,24 +237,23 @@ export namespace Server {
}, },
}), }),
zValidator( zValidator(
"json", "param",
z.object({ z.object({
sessionID: z.string(), id: z.string(),
}), }),
), ),
async (c) => { async (c) => {
const body = c.req.valid("json") await Session.remove(c.req.valid("param").id)
await Session.remove(body.sessionID)
return c.json(true) return c.json(true)
}, },
) )
.post( .post(
"/session_summarize", "/session/:id/init",
describeRoute({ describeRoute({
description: "Summarize the session", description: "Analyze the app and create an AGENTS.md file",
responses: { responses: {
200: { 200: {
description: "Summarize the session", description: "200",
content: { content: {
"application/json": { "application/json": {
schema: resolver(z.boolean()), schema: resolver(z.boolean()),
@@ -432,27 +262,175 @@ export namespace Server {
}, },
}, },
}), }),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator( zValidator(
"json", "json",
z.object({ z.object({
sessionID: z.string(),
providerID: z.string(), providerID: z.string(),
modelID: z.string(), modelID: z.string(),
}), }),
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json") const body = c.req.valid("json")
await Session.summarize(body) await Session.initialize({ ...body, sessionID })
return c.json(true) return c.json(true)
}, },
) )
.post( .post(
"/session_chat", "/session/:id/abort",
describeRoute({ describeRoute({
description: "Chat with a model", description: "Abort a session",
responses: { responses: {
200: { 200: {
description: "Chat with a model", description: "Aborted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
return c.json(Session.abort(c.req.valid("param").id))
},
)
.post(
"/session/:id/share",
describeRoute({
description: "Share a session",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
await Session.share(id)
const session = await Session.get(id)
return c.json(session)
},
)
.delete(
"/session/:id/share",
describeRoute({
description: "Unshare the session",
responses: {
200: {
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
await Session.unshare(id)
const session = await Session.get(id)
return c.json(session)
},
)
.post(
"/session/:id/summarize",
describeRoute({
description: "Summarize the session",
responses: {
200: {
description: "Summarized session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator(
"json",
z.object({
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
const body = c.req.valid("json")
await Session.summarize({ ...body, sessionID: id })
return c.json(true)
},
)
.get(
"/session/:id/message",
describeRoute({
description: "List messages for a session",
responses: {
200: {
description: "List of messages",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
async (c) => {
const messages = await Session.messages(c.req.valid("param").id)
return c.json(messages)
},
)
.post(
"/session/:id/message",
describeRoute({
description: "Create and send a new message to a session",
responses: {
200: {
description: "Created message",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Message.Info), schema: resolver(Message.Info),
@@ -461,23 +439,29 @@ export namespace Server {
}, },
}, },
}), }),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator( zValidator(
"json", "json",
z.object({ z.object({
sessionID: z.string(),
providerID: z.string(), providerID: z.string(),
modelID: z.string(), modelID: z.string(),
parts: Message.Part.array(), parts: Message.MessagePart.array(),
}), }),
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json") const body = c.req.valid("json")
const msg = await Session.chat(body) const msg = await Session.chat({ ...body, sessionID })
return c.json(msg) return c.json(msg)
}, },
) )
.post( .get(
"/provider_list", "/config/providers",
describeRoute({ describeRoute({
description: "List all providers", description: "List all providers",
responses: { responses: {
@@ -509,13 +493,45 @@ export namespace Server {
}) })
}, },
) )
.post( .get(
"/file_search", "/find",
describeRoute({ describeRoute({
description: "Search for files", description: "Find text in files",
responses: { responses: {
200: { 200: {
description: "Search for files", description: "Matches",
content: {
"application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
pattern: z.string(),
}),
),
async (c) => {
const app = App.info()
const pattern = c.req.valid("query").pattern
const result = await Ripgrep.search({
cwd: app.path.cwd,
pattern,
limit: 10,
})
return c.json(result)
},
)
.get(
"/find/file",
describeRoute({
description: "Find files",
responses: {
200: {
description: "File paths",
content: { content: {
"application/json": { "application/json": {
schema: resolver(z.string().array()), schema: resolver(z.string().array()),
@@ -525,39 +541,112 @@ export namespace Server {
}, },
}), }),
zValidator( zValidator(
"json", "query",
z.object({ z.object({
query: z.string(), query: z.string(),
}), }),
), ),
async (c) => { async (c) => {
const body = c.req.valid("json") const query = c.req.valid("query").query
const app = App.info() const app = App.info()
const result = await Ripgrep.files({ const result = await Ripgrep.files({
cwd: app.path.cwd, cwd: app.path.cwd,
query: body.query, query,
limit: 10, limit: 10,
}) })
return c.json(result) return c.json(result)
}, },
) )
.post( .get(
"installation_info", "/find/symbol",
describeRoute({ describeRoute({
description: "Get installation info", description: "Find workspace symbols",
responses: { responses: {
200: { 200: {
description: "Get installation info", description: "Symbols",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Installation.Info), schema: resolver(z.unknown().array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
query: z.string(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
},
)
.get(
"/file",
describeRoute({
description: "Read a file",
responses: {
200: {
description: "File content",
content: {
"application/json": {
schema: resolver(
z.object({
type: z.enum(["raw", "patch"]),
content: z.string(),
}),
),
},
},
},
},
}),
zValidator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
log.info("read file", {
path,
content: content.content,
})
return c.json(content)
},
)
.get(
"/file/status",
describeRoute({
description: "Get file status",
responses: {
200: {
description: "File status",
content: {
"application/json": {
schema: resolver(
z
.object({
file: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.array(),
),
}, },
}, },
}, },
}, },
}), }),
async (c) => { async (c) => {
return c.json(Installation.info()) const content = await File.status()
return c.json(content)
}, },
) )
@@ -579,10 +668,10 @@ export namespace Server {
return result return result
} }
export function listen() { export function listen(opts: { port: number; hostname: string }) {
const server = Bun.serve({ const server = Bun.serve({
port: 0, port: opts.port,
hostname: "0.0.0.0", hostname: opts.hostname,
idleTimeout: 0, idleTimeout: 0,
fetch: app().fetch, fetch: app().fetch,
}) })
+139 -19
View File
@@ -34,6 +34,7 @@ import type { ModelsDev } from "../provider/models"
import { Installation } from "../installation" import { Installation } from "../installation"
import { Config } from "../config/config" import { Config } from "../config/config"
import { ProviderTransform } from "../provider/transform" import { ProviderTransform } from "../provider/transform"
import { Snapshot } from "../snapshot"
export namespace Session { export namespace Session {
const log = Log.create({ service: "session" }) const log = Log.create({ service: "session" })
@@ -53,16 +54,27 @@ export namespace Session {
created: z.number(), created: z.number(),
updated: z.number(), updated: z.number(),
}), }),
revert: z
.object({
messageID: z.string(),
part: z.number(),
snapshot: z.string().optional(),
})
.optional(),
}) })
.openapi({ .openapi({
ref: "session.info", ref: "Session",
}) })
export type Info = z.output<typeof Info> export type Info = z.output<typeof Info>
export const ShareInfo = z.object({ export const ShareInfo = z
secret: z.string(), .object({
url: z.string(), secret: z.string(),
}) url: z.string(),
})
.openapi({
ref: "SessionShare",
})
export type ShareInfo = z.output<typeof ShareInfo> export type ShareInfo = z.output<typeof ShareInfo>
export const Event = { export const Event = {
@@ -78,6 +90,12 @@ export namespace Session {
info: Info, info: Info,
}), }),
), ),
Idle: Bus.event(
"session.idle",
z.object({
sessionID: z.string(),
}),
),
Error: Bus.event( Error: Bus.event(
"session.error", "session.error",
z.object({ z.object({
@@ -267,7 +285,7 @@ export namespace Session {
sessionID: string sessionID: string
providerID: string providerID: string
modelID: string modelID: string
parts: Message.Part[] parts: Message.MessagePart[]
system?: string[] system?: string[]
tools?: Tool.Info[] tools?: Tool.Info[]
}) { }) {
@@ -275,6 +293,37 @@ export namespace Session {
l.info("chatting") l.info("chatting")
const model = await Provider.getModel(input.providerID, input.modelID) const model = await Provider.getModel(input.providerID, input.modelID)
let msgs = await messages(input.sessionID) let msgs = await messages(input.sessionID)
const session = await get(input.sessionID)
if (session.revert) {
const trimmed = []
for (const msg of msgs) {
if (
msg.id > session.revert.messageID ||
(msg.id === session.revert.messageID && session.revert.part === 0)
) {
await Storage.remove(
"session/message/" + input.sessionID + "/" + msg.id,
)
await Bus.publish(Message.Event.Removed, {
sessionID: input.sessionID,
messageID: msg.id,
})
continue
}
if (msg.id === session.revert.messageID) {
if (session.revert.part === 0) break
msg.parts = msg.parts.slice(0, session.revert.part)
}
trimmed.push(msg)
}
msgs = trimmed
await update(input.sessionID, (draft) => {
draft.revert = undefined
})
}
const previous = msgs.at(-1) const previous = msgs.at(-1)
// auto summarize if too long // auto summarize if too long
@@ -287,7 +336,10 @@ export namespace Session {
if ( if (
model.info.limit.context && model.info.limit.context &&
tokens > 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({ await summarize({
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -306,7 +358,6 @@ export namespace Session {
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id) if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
const app = App.info() const app = App.info()
const session = await get(input.sessionID)
if (msgs.length === 0 && !session.parentID) { if (msgs.length === 0 && !session.parentID) {
generateText({ generateText({
maxTokens: input.providerID === "google" ? 1024 : 20, maxTokens: input.providerID === "google" ? 1024 : 20,
@@ -336,6 +387,7 @@ export namespace Session {
}) })
.catch(() => {}) .catch(() => {})
} }
const snapshot = await Snapshot.create(input.sessionID)
const msg: Message.Info = { const msg: Message.Info = {
role: "user", role: "user",
id: Identifier.ascending("message"), id: Identifier.ascending("message"),
@@ -346,6 +398,7 @@ export namespace Session {
}, },
sessionID: input.sessionID, sessionID: input.sessionID,
tool: {}, tool: {},
snapshot,
}, },
} }
await updateMessage(msg) await updateMessage(msg)
@@ -360,6 +413,7 @@ export namespace Session {
role: "assistant", role: "assistant",
parts: [], parts: [],
metadata: { metadata: {
snapshot,
assistant: { assistant: {
system, system,
path: { path: {
@@ -411,6 +465,7 @@ export namespace Session {
}) })
next.metadata!.tool![opts.toolCallId] = { next.metadata!.tool![opts.toolCallId] = {
...result.metadata, ...result.metadata,
snapshot: await Snapshot.create(input.sessionID),
time: { time: {
start, start,
end: Date.now(), end: Date.now(),
@@ -423,6 +478,7 @@ export namespace Session {
error: true, error: true,
message: e.toString(), message: e.toString(),
title: e.toString(), title: e.toString(),
snapshot: await Snapshot.create(input.sessionID),
time: { time: {
start, start,
end: Date.now(), end: Date.now(),
@@ -444,6 +500,7 @@ export namespace Session {
const result = await execute(args, opts) const result = await execute(args, opts)
next.metadata!.tool![opts.toolCallId] = { next.metadata!.tool![opts.toolCallId] = {
...result.metadata, ...result.metadata,
snapshot: await Snapshot.create(input.sessionID),
time: { time: {
start, start,
end: Date.now(), end: Date.now(),
@@ -458,6 +515,7 @@ export namespace Session {
next.metadata!.tool![opts.toolCallId] = { next.metadata!.tool![opts.toolCallId] = {
error: true, error: true,
message: e.toString(), message: e.toString(),
snapshot: await Snapshot.create(input.sessionID),
title: "mcp", title: "mcp",
time: { time: {
start, start,
@@ -489,15 +547,6 @@ export namespace Session {
} }
text = undefined text = undefined
}, },
async onFinish(input) {
log.info("message finish", {
reason: input.finishReason,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, input.usage, input.providerMetadata)
assistant.cost = usage.cost
await updateMessage(next)
},
onError(err) { onError(err) {
log.error("callback error", err) log.error("callback error", err)
switch (true) { switch (true) {
@@ -534,6 +583,7 @@ export namespace Session {
// return step // return step
// }, // },
toolCallStreaming: true, toolCallStreaming: true,
maxTokens: Math.max(0, model.info.limit.output) || undefined,
abortSignal: abort.signal, abortSignal: abort.signal,
maxSteps: 1000, maxSteps: 1000,
providerOptions: model.info.options, providerOptions: model.info.options,
@@ -657,6 +707,21 @@ export namespace Session {
} }
break 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: default:
l.info("unhandled", { l.info("unhandled", {
type: value.type, type: value.type,
@@ -670,6 +735,9 @@ export namespace Session {
error: e, error: e,
}) })
switch (true) { switch (true) {
case Message.OutputLengthError.isInstance(e):
next.metadata.error = e
break
case LoadAPIKeyError.isInstance(e): case LoadAPIKeyError.isInstance(e):
next.metadata.error = new Provider.AuthError( next.metadata.error = new Provider.AuthError(
{ {
@@ -712,6 +780,51 @@ export namespace Session {
return next return next
} }
export async function revert(input: {
sessionID: string
messageID: string
part: number
}) {
const message = await getMessage(input.sessionID, input.messageID)
if (!message) return
const part = message.parts[input.part]
if (!part) return
const session = await get(input.sessionID)
const snapshot =
session.revert?.snapshot ?? (await Snapshot.create(input.sessionID))
const old = (() => {
if (message.role === "assistant") {
const lastTool = message.parts.findLast(
(part, index) =>
part.type === "tool-invocation" && index < input.part,
)
if (lastTool && lastTool.type === "tool-invocation")
return message.metadata.tool[lastTool.toolInvocation.toolCallId]
.snapshot
}
return message.metadata.snapshot
})()
if (old) await Snapshot.restore(input.sessionID, old)
await update(input.sessionID, (draft) => {
draft.revert = {
messageID: input.messageID,
part: input.part,
snapshot,
}
})
}
export async function unrevert(sessionID: string) {
const session = await get(sessionID)
if (!session) return
if (!session.revert) return
if (session.revert.snapshot)
await Snapshot.restore(sessionID, session.revert.snapshot)
update(sessionID, (draft) => {
draft.revert = undefined
})
}
export async function summarize(input: { export async function summarize(input: {
sessionID: string sessionID: string
providerID: string providerID: string
@@ -798,7 +911,7 @@ export namespace Session {
async onFinish(input) { async onFinish(input) {
const assistant = next.metadata!.assistant! const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, input.usage, input.providerMetadata) const usage = getUsage(model.info, input.usage, input.providerMetadata)
assistant.cost = usage.cost assistant.cost += usage.cost
assistant.tokens = usage.tokens assistant.tokens = usage.tokens
next.metadata!.time.completed = Date.now() next.metadata!.time.completed = Date.now()
await updateMessage(next) await updateMessage(next)
@@ -832,6 +945,9 @@ export namespace Session {
[Symbol.dispose]() { [Symbol.dispose]() {
log.info("unlocking", { sessionID }) log.info("unlocking", { sessionID })
state().pending.delete(sessionID) state().pending.delete(sessionID)
Bus.publish(Event.Idle, {
sessionID,
})
}, },
} }
} }
@@ -847,8 +963,12 @@ export namespace Session {
reasoning: 0, reasoning: 0,
cache: { cache: {
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
0) as number, 0) as number,
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ?? read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
// @ts-expect-error
metadata?.["bedrock"]?.["usage"]?.["cacheReadInputTokens"] ??
0) as number, 0) as number,
}, },
} }
@@ -920,7 +1040,7 @@ function toUIMessage(msg: Message.Info): UIMessage {
throw new Error("not implemented") throw new Error("not implemented")
} }
function toParts(parts: Message.Part[]): UIMessage["parts"] { function toParts(parts: Message.MessagePart[]): UIMessage["parts"] {
const result: UIMessage["parts"] = [] const result: UIMessage["parts"] = []
for (const part of parts) { for (const part of parts) {
switch (part.type) { switch (part.type) {
+36 -17
View File
@@ -4,6 +4,11 @@ import { Provider } from "../provider/provider"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
export namespace Message { export namespace Message {
export const OutputLengthError = NamedError.create(
"MessageOutputLengthError",
z.object({}),
)
export const ToolCall = z export const ToolCall = z
.object({ .object({
state: z.literal("call"), state: z.literal("call"),
@@ -13,7 +18,7 @@ export namespace Message {
args: z.custom<Required<unknown>>(), args: z.custom<Required<unknown>>(),
}) })
.openapi({ .openapi({
ref: "Message.ToolInvocation.ToolCall", ref: "ToolCall",
}) })
export type ToolCall = z.infer<typeof ToolCall> export type ToolCall = z.infer<typeof ToolCall>
@@ -26,7 +31,7 @@ export namespace Message {
args: z.custom<Required<unknown>>(), args: z.custom<Required<unknown>>(),
}) })
.openapi({ .openapi({
ref: "Message.ToolInvocation.ToolPartialCall", ref: "ToolPartialCall",
}) })
export type ToolPartialCall = z.infer<typeof ToolPartialCall> export type ToolPartialCall = z.infer<typeof ToolPartialCall>
@@ -40,14 +45,14 @@ export namespace Message {
result: z.string(), result: z.string(),
}) })
.openapi({ .openapi({
ref: "Message.ToolInvocation.ToolResult", ref: "ToolResult",
}) })
export type ToolResult = z.infer<typeof ToolResult> export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocation = z export const ToolInvocation = z
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]) .discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
.openapi({ .openapi({
ref: "Message.ToolInvocation", ref: "ToolInvocation",
}) })
export type ToolInvocation = z.infer<typeof ToolInvocation> export type ToolInvocation = z.infer<typeof ToolInvocation>
@@ -57,7 +62,7 @@ export namespace Message {
text: z.string(), text: z.string(),
}) })
.openapi({ .openapi({
ref: "Message.Part.Text", ref: "TextPart",
}) })
export type TextPart = z.infer<typeof TextPart> export type TextPart = z.infer<typeof TextPart>
@@ -68,7 +73,7 @@ export namespace Message {
providerMetadata: z.record(z.any()).optional(), providerMetadata: z.record(z.any()).optional(),
}) })
.openapi({ .openapi({
ref: "Message.Part.Reasoning", ref: "ReasoningPart",
}) })
export type ReasoningPart = z.infer<typeof ReasoningPart> export type ReasoningPart = z.infer<typeof ReasoningPart>
@@ -78,7 +83,7 @@ export namespace Message {
toolInvocation: ToolInvocation, toolInvocation: ToolInvocation,
}) })
.openapi({ .openapi({
ref: "Message.Part.ToolInvocation", ref: "ToolInvocationPart",
}) })
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart> export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
@@ -91,7 +96,7 @@ export namespace Message {
providerMetadata: z.record(z.any()).optional(), providerMetadata: z.record(z.any()).optional(),
}) })
.openapi({ .openapi({
ref: "Message.Part.SourceUrl", ref: "SourceUrlPart",
}) })
export type SourceUrlPart = z.infer<typeof SourceUrlPart> export type SourceUrlPart = z.infer<typeof SourceUrlPart>
@@ -103,7 +108,7 @@ export namespace Message {
url: z.string(), url: z.string(),
}) })
.openapi({ .openapi({
ref: "Message.Part.File", ref: "FilePart",
}) })
export type FilePart = z.infer<typeof FilePart> export type FilePart = z.infer<typeof FilePart>
@@ -112,11 +117,11 @@ export namespace Message {
type: z.literal("step-start"), type: z.literal("step-start"),
}) })
.openapi({ .openapi({
ref: "Message.Part.StepStart", ref: "StepStartPart",
}) })
export type StepStartPart = z.infer<typeof StepStartPart> export type StepStartPart = z.infer<typeof StepStartPart>
export const Part = z export const MessagePart = z
.discriminatedUnion("type", [ .discriminatedUnion("type", [
TextPart, TextPart,
ReasoningPart, ReasoningPart,
@@ -126,15 +131,15 @@ export namespace Message {
StepStartPart, StepStartPart,
]) ])
.openapi({ .openapi({
ref: "Message.Part", ref: "MessagePart",
}) })
export type Part = z.infer<typeof Part> export type MessagePart = z.infer<typeof MessagePart>
export const Info = z export const Info = z
.object({ .object({
id: z.string(), id: z.string(),
role: z.enum(["user", "assistant"]), role: z.enum(["user", "assistant"]),
parts: z.array(Part), parts: z.array(MessagePart),
metadata: z metadata: z
.object({ .object({
time: z.object({ time: z.object({
@@ -145,6 +150,7 @@ export namespace Message {
.discriminatedUnion("name", [ .discriminatedUnion("name", [
Provider.AuthError.Schema, Provider.AuthError.Schema,
NamedError.Unknown.Schema, NamedError.Unknown.Schema,
OutputLengthError.Schema,
]) ])
.optional(), .optional(),
sessionID: z.string(), sessionID: z.string(),
@@ -153,6 +159,7 @@ export namespace Message {
z z
.object({ .object({
title: z.string(), title: z.string(),
snapshot: z.string().optional(),
time: z.object({ time: z.object({
start: z.number(), start: z.number(),
end: z.number(), end: z.number(),
@@ -182,11 +189,12 @@ export namespace Message {
}), }),
}) })
.optional(), .optional(),
snapshot: z.string().optional(),
}) })
.openapi({ ref: "Message.Metadata" }), .openapi({ ref: "MessageMetadata" }),
}) })
.openapi({ .openapi({
ref: "Message.Info", ref: "Message",
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
@@ -197,9 +205,20 @@ export namespace Message {
info: Info, info: Info,
}), }),
), ),
Removed: Bus.event(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
}),
),
PartUpdated: Bus.event( PartUpdated: Bus.event(
"message.part.updated", "message.part.updated",
z.object({ part: Part, sessionID: z.string(), messageID: z.string() }), z.object({
part: MessagePart,
sessionID: z.string(),
messageID: z.string(),
}),
), ),
} }
} }
+11 -53
View File
@@ -1,5 +1,5 @@
import { App } from "../app/app" import { App } from "../app/app"
import { Ripgrep } from "../external/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Global } from "../global" import { Global } from "../global"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import path from "path" import path from "path"
@@ -27,55 +27,6 @@ export namespace SystemPrompt {
export async function environment() { export async function environment() {
const app = App.info() 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 [ return [
[ [
`Here is some useful information about the environment you are running in:`, `Here is some useful information about the environment you are running in:`,
@@ -85,9 +36,16 @@ export namespace SystemPrompt {
` Platform: ${process.platform}`, ` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`, ` Today's date: ${new Date().toDateString()}`,
`</env>`, `</env>`,
// `<project>`, `<project>`,
// ` ${app.git ? await tree() : ""}`, ` ${
// `</project>`, app.git
? await Ripgrep.tree({
cwd: app.path.cwd,
limit: 200,
})
: ""
}`,
`</project>`,
].join("\n"), ].join("\n"),
] ]
} }
+4 -9
View File
@@ -1,4 +1,3 @@
import { App } from "../app/app"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Installation } from "../installation" import { Installation } from "../installation"
import { Session } from "../session" import { Session } from "../session"
@@ -11,12 +10,6 @@ export namespace Share {
let queue: Promise<void> = Promise.resolve() let queue: Promise<void> = Promise.resolve()
const pending = new Map<string, any>() const pending = new Map<string, any>()
const state = App.state("share", async () => {
Bus.subscribe(Storage.Event.Write, async (payload) => {
await sync(payload.properties.key, payload.properties.content)
})
})
export async function sync(key: string, content: any) { export async function sync(key: string, content: any) {
const [root, ...splits] = key.split("/") const [root, ...splits] = key.split("/")
if (root !== "session") return if (root !== "session") return
@@ -52,8 +45,10 @@ export namespace Share {
}) })
} }
export async function init() { export function init() {
await state() Bus.subscribe(Storage.Event.Write, async (payload) => {
await sync(payload.properties.key, payload.properties.content)
})
} }
export const URL = export const URL =
+85
View File
@@ -0,0 +1,85 @@
import { App } from "../app/app"
import {
add,
commit,
init,
checkout,
statusMatrix,
remove,
} from "isomorphic-git"
import path from "path"
import fs from "fs"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
const app = App.info()
const git = gitdir(sessionID)
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: app.git ? undefined : 1000,
})
// not a git repo and too big to snapshot
if (!app.git && files.length === 1000) return
await init({
dir: app.path.cwd,
gitdir: git,
fs,
})
const status = await statusMatrix({
fs,
gitdir: git,
dir: app.path.cwd,
})
await add({
fs,
gitdir: git,
parallel: true,
dir: app.path.cwd,
filepath: files,
})
for (const [file, _head, workdir, stage] of status) {
if (workdir === 0 && stage === 1) {
log.info("remove", { file })
await remove({
fs,
gitdir: git,
dir: app.path.cwd,
filepath: file,
})
}
}
const result = await commit({
fs,
gitdir: git,
dir: app.path.cwd,
message: "snapshot",
author: {
name: "opencode",
email: "mail@opencode.ai",
},
})
log.info("commit", { result })
return result
}
export async function restore(sessionID: string, commit: string) {
log.info("restore", { commit })
const app = App.info()
await checkout({
fs,
gitdir: gitdir(sessionID),
dir: app.path.cwd,
ref: commit,
force: true,
})
}
function gitdir(sessionID: string) {
const app = App.info()
return path.join(app.path.data, "snapshot", sessionID)
}
}
+2
View File
@@ -1,6 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { Tool } from "./tool" import { Tool } from "./tool"
import DESCRIPTION from "./bash.txt" import DESCRIPTION from "./bash.txt"
import { App } from "../app/app"
const MAX_OUTPUT_LENGTH = 30000 const MAX_OUTPUT_LENGTH = 30000
const BANNED_COMMANDS = [ const BANNED_COMMANDS = [
@@ -49,6 +50,7 @@ export const BashTool = Tool.define({
const process = Bun.spawn({ const process = Bun.spawn({
cmd: ["bash", "-c", params.command], cmd: ["bash", "-c", params.command],
cwd: App.info().path.cwd,
maxBuffer: MAX_OUTPUT_LENGTH, maxBuffer: MAX_OUTPUT_LENGTH,
signal: ctx.abort, signal: ctx.abort,
timeout: timeout, timeout: timeout,
+387 -27
View File
@@ -1,12 +1,18 @@
// the approaches in this edit tool are sourced from
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
import { z } from "zod" import { z } from "zod"
import * as path from "path" import * as path from "path"
import { Tool } from "./tool" import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { createTwoFilesPatch } from "diff" import { createTwoFilesPatch } from "diff"
import { Permission } from "../permission" import { Permission } from "../permission"
import DESCRIPTION from "./edit.txt" import DESCRIPTION from "./edit.txt"
import { App } from "../app/app" import { App } from "../app/app"
import { File } from "../file"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
export const EditTool = Tool.define({ export const EditTool = Tool.define({
id: "edit", id: "edit",
@@ -29,6 +35,10 @@ export const EditTool = Tool.define({
throw new Error("filePath is required") 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 app = App.info()
const filepath = path.isAbsolute(params.filePath) const filepath = path.isAbsolute(params.filePath)
? params.filePath ? params.filePath
@@ -51,47 +61,38 @@ export const EditTool = Tool.define({
if (params.oldString === "") { if (params.oldString === "") {
contentNew = params.newString contentNew = params.newString
await Bun.write(filepath, params.newString) await Bun.write(filepath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
return return
} }
const file = Bun.file(filepath) const file = Bun.file(filepath)
if (!(await file.exists())) throw new Error(`File ${filepath} not found`) const stats = await file.stat().catch(() => {})
const stats = await file.stat() if (!stats) throw new Error(`File ${filepath} not found`)
if (stats.isDirectory()) if (stats.isDirectory())
throw new Error(`Path is a directory, not a file: ${filepath}`) throw new Error(`Path is a directory, not a file: ${filepath}`)
await FileTimes.assert(ctx.sessionID, filepath) await FileTime.assert(ctx.sessionID, filepath)
contentOld = await file.text() 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 file.write(contentNew)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
contentNew = await file.text()
})() })()
const diff = trimDiff( const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew), createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
) )
FileTimes.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
let output = "" let output = ""
await LSP.touchFile(filepath, true) await LSP.touchFile(filepath, true)
@@ -116,6 +117,326 @@ 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 { function trimDiff(diff: string): string {
const lines = diff.split("\n") const lines = diff.split("\n")
const contentLines = lines.filter( const contentLines = lines.filter(
@@ -151,3 +472,42 @@ function trimDiff(diff: string): string {
return trimmedLines.join("\n") 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")
}
+1 -1
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Tool } from "./tool" import { Tool } from "./tool"
import { App } from "../app/app" import { App } from "../app/app"
import DESCRIPTION from "./glob.txt" import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../external/ripgrep" import { Ripgrep } from "../file/ripgrep"
export const GlobTool = Tool.define({ export const GlobTool = Tool.define({
id: "glob", id: "glob",
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { Tool } from "./tool" import { Tool } from "./tool"
import { App } from "../app/app" import { App } from "../app/app"
import { Ripgrep } from "../external/ripgrep" import { Ripgrep } from "../file/ripgrep"
import DESCRIPTION from "./grep.txt" import DESCRIPTION from "./grep.txt"
+3 -3
View File
@@ -2,7 +2,7 @@ import { z } from "zod"
import * as path from "path" import * as path from "path"
import * as fs from "fs/promises" import * as fs from "fs/promises"
import { Tool } from "./tool" import { Tool } from "./tool"
import { FileTimes } from "./util/file-times" import { FileTime } from "../file/time"
import DESCRIPTION from "./patch.txt" import DESCRIPTION from "./patch.txt"
const PatchParams = z.object({ const PatchParams = z.object({
@@ -244,7 +244,7 @@ export const PatchTool = Tool.define({
absPath = path.resolve(process.cwd(), absPath) absPath = path.resolve(process.cwd(), absPath)
} }
await FileTimes.assert(ctx.sessionID, absPath) await FileTime.assert(ctx.sessionID, absPath)
try { try {
const stats = await fs.stat(absPath) const stats = await fs.stat(absPath)
@@ -351,7 +351,7 @@ export const PatchTool = Tool.define({
totalAdditions += additions totalAdditions += additions
totalRemovals += removals totalRemovals += removals
FileTimes.read(ctx.sessionID, absPath) FileTime.read(ctx.sessionID, absPath)
} }
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals` const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
+3 -3
View File
@@ -3,7 +3,7 @@ import * as fs from "fs"
import * as path from "path" import * as path from "path"
import { Tool } from "./tool" import { Tool } from "./tool"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileTimes } from "./util/file-times" import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt" import DESCRIPTION from "./read.txt"
import { App } from "../app/app" import { App } from "../app/app"
@@ -89,8 +89,8 @@ export const ReadTool = Tool.define({
output += "\n</file>" output += "\n</file>"
// just warms the lsp client // just warms the lsp client
await LSP.touchFile(filePath, true) await LSP.touchFile(filePath, false)
FileTimes.read(ctx.sessionID, filePath) FileTime.read(ctx.sessionID, filePath)
return { return {
output, output,
+1 -1
View File
@@ -7,7 +7,7 @@ Usage:
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any lines longer than 2000 characters will be truncated - Any lines longer than 2000 characters will be truncated
- Results are returned using cat -n format, with line numbers starting at 1 - Results are returned using cat -n format, with line numbers starting at 1
- This tool allows OpenCode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as OpenCode is a multimodal LLM. - This tool allows opencode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as opencode is a multimodal LLM.
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. - You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png - You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
+8 -3
View File
@@ -1,11 +1,13 @@
import { z } from "zod" import { z } from "zod"
import * as path from "path" import * as path from "path"
import { Tool } from "./tool" import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { Permission } from "../permission" import { Permission } from "../permission"
import DESCRIPTION from "./write.txt" import DESCRIPTION from "./write.txt"
import { App } from "../app/app" import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { FileTime } from "../file/time"
export const WriteTool = Tool.define({ export const WriteTool = Tool.define({
id: "write", id: "write",
@@ -26,7 +28,7 @@ export const WriteTool = Tool.define({
const file = Bun.file(filepath) const file = Bun.file(filepath)
const exists = await file.exists() const exists = await file.exists()
if (exists) await FileTimes.assert(ctx.sessionID, filepath) if (exists) await FileTime.assert(ctx.sessionID, filepath)
await Permission.ask({ await Permission.ask({
id: "write", id: "write",
@@ -42,7 +44,10 @@ export const WriteTool = Tool.define({
}) })
await Bun.write(filepath, params.content) await Bun.write(filepath, params.content)
FileTimes.read(ctx.sessionID, filepath) await Bus.publish(File.Event.Edited, {
file: filepath,
})
FileTime.read(ctx.sessionID, filepath)
let output = "" let output = ""
await LSP.touchFile(filepath, true) await LSP.touchFile(filepath, true)
+1 -1
View File
@@ -4,8 +4,8 @@ export function lazy<T>(fn: () => T) {
return (): T => { return (): T => {
if (loaded) return value as T if (loaded) return value as T
loaded = true
value = fn() value = fn()
return value as T return value as T
} }
} }
+4 -1
View File
@@ -19,7 +19,10 @@ export namespace Log {
await fs.mkdir(dir, { recursive: true }) await fs.mkdir(dir, { recursive: true })
cleanup(dir) cleanup(dir)
if (options.print) return 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) const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {}) await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer() const writer = logfile.writer()
+14
View File
@@ -0,0 +1,14 @@
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timeout: NodeJS.Timeout
return Promise.race([
promise.then((result) => {
clearTimeout(timeout)
return result
}),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`Operation timed out after ${ms}ms`))
}, ms)
}),
])
}
+1 -1
View File
@@ -6,4 +6,4 @@
/// <reference path="../../sst-env.d.ts" /> /// <reference path="../../sst-env.d.ts" />
import "sst" import "sst"
export {} export {}
+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)
}
})
})
+1
View File
@@ -0,0 +1 @@
opencode-test
+25
View File
@@ -0,0 +1,25 @@
# TUI Agent Guidelines
## Build/Test Commands
- **Build**: `go build ./cmd/opencode` (builds main binary)
- **Test**: `go test ./...` (runs all tests)
- **Single test**: `go test ./internal/theme -run TestLoadThemesFromJSON` (specific test)
- **Release build**: Uses `.goreleaser.yml` configuration
## Code Style
- **Language**: Go 1.24+ with standard formatting (`gofmt`)
- **Imports**: Group standard, third-party, local packages with blank lines
- **Naming**: Go conventions - PascalCase exports, camelCase private, ALL_CAPS constants
- **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping
- **Structs**: Define clear interfaces, embed when appropriate
- **Testing**: Use table-driven tests, `t.TempDir()` for file operations
## Architecture
- **TUI Framework**: Bubble Tea v2 with Lipgloss v2 for styling
- **Client**: Generated OpenAPI client communicates with TypeScript server
- **Components**: Reusable UI components in `internal/components/`
- **Themes**: JSON-based theming system with override hierarchy
- **State**: Centralized app state with message passing
+17 -17
View File
@@ -9,9 +9,10 @@ import (
"strings" "strings"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode-sdk-go/option"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/tui" "github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/pkg/client"
) )
var Version = "dev" var Version = "dev"
@@ -25,7 +26,7 @@ func main() {
url := os.Getenv("OPENCODE_SERVER") url := os.Getenv("OPENCODE_SERVER")
appInfoStr := os.Getenv("OPENCODE_APP_INFO") appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo client.AppInfo var appInfo opencode.App
err := json.Unmarshal([]byte(appInfoStr), &appInfo) err := json.Unmarshal([]byte(appInfoStr), &appInfo)
if err != nil { if err != nil {
slog.Error("Failed to unmarshal app info", "error", err) slog.Error("Failed to unmarshal app info", "error", err)
@@ -49,7 +50,12 @@ func main() {
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug})) logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger) slog.SetDefault(logger)
httpClient, err := client.NewClientWithResponses(url) slog.Debug("TUI launched", "app", appInfo)
httpClient := opencode.NewClient(
option.WithBaseURL(url),
)
if err != nil { if err != nil {
slog.Error("Failed to create client", "error", err) slog.Error("Failed to create client", "error", err)
os.Exit(1) os.Exit(1)
@@ -71,21 +77,15 @@ func main() {
tea.WithMouseCellMotion(), tea.WithMouseCellMotion(),
) )
eventClient, err := client.NewClient(url)
if err != nil {
slog.Error("Failed to create event client", "error", err)
os.Exit(1)
}
evts, err := eventClient.Event(ctx)
if err != nil {
slog.Error("Failed to subscribe to events", "error", err)
os.Exit(1)
}
go func() { go func() {
for item := range evts { stream := httpClient.Event.ListStreaming(ctx)
program.Send(item) for stream.Next() {
evt := stream.Current().AsUnion()
program.Send(evt)
}
if err := stream.Err(); err != nil {
slog.Error("Error streaming events", "error", err)
program.Send(err)
} }
}() }()
+7 -5
View File
@@ -14,8 +14,9 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0 github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/oapi-codegen/runtime v1.1.1
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
github.com/tidwall/gjson v1.14.4
rsc.io/qr v0.2.0 rsc.io/qr v0.2.0
) )
@@ -23,7 +24,6 @@ require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
require ( require (
dario.cat/mergo v1.0.2 // indirect 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/atombender/go-jsonschema v0.20.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // 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/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
@@ -35,7 +35,6 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect github.com/goccy/go-yaml v1.17.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect github.com/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
@@ -48,6 +47,9 @@ require (
github.com/sosodev/duration v1.3.1 // indirect github.com/sosodev/duration v1.3.1 // indirect
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/cobra v1.9.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
golang.org/x/mod v0.24.0 // indirect golang.org/x/mod v0.24.0 // indirect
golang.org/x/tools v0.31.0 // indirect golang.org/x/tools v0.31.0 // indirect
@@ -68,10 +70,10 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+12 -11
View File
@@ -4,15 +4,12 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4= github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -23,7 +20,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
@@ -96,8 +92,6 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -110,7 +104,6 @@ github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -148,8 +141,6 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q= github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8= github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
@@ -190,14 +181,24 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
+114 -150
View File
@@ -11,34 +11,34 @@ import (
"log/slog" "log/slog"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast" "github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
) )
var RootPath string
type App struct { type App struct {
Info client.AppInfo Info opencode.App
Version string Version string
StatePath string StatePath string
Config *client.ConfigInfo Config *opencode.Config
Client *client.ClientWithResponses Client *opencode.Client
State *config.State State *config.State
Provider *client.ProviderInfo Provider *opencode.Provider
Model *client.ModelInfo Model *opencode.Model
Session *client.SessionInfo Session *opencode.Session
Messages []client.MessageInfo Messages []opencode.Message
Commands commands.CommandRegistry Commands commands.CommandRegistry
} }
type SessionSelectedMsg = *client.SessionInfo type SessionSelectedMsg = *opencode.Session
type SessionLoadedMsg struct{}
type ModelSelectedMsg struct { type ModelSelectedMsg struct {
Provider client.ProviderInfo Provider opencode.Provider
Model client.ModelInfo Model opencode.Model
} }
type SessionClearedMsg struct{} type SessionClearedMsg struct{}
type CompactSessionMsg struct{} type CompactSessionMsg struct{}
@@ -50,31 +50,28 @@ type CompletionDialogTriggeredMsg struct {
InitialValue string InitialValue string
} }
type OptimisticMessageAddedMsg struct { type OptimisticMessageAddedMsg struct {
Message client.MessageInfo Message opencode.Message
}
type FileRenderedMsg struct {
FilePath string
} }
func New( func New(
ctx context.Context, ctx context.Context,
version string, version string,
appInfo client.AppInfo, appInfo opencode.App,
httpClient *client.ClientWithResponses, httpClient *opencode.Client,
) (*App, error) { ) (*App, error) {
RootPath = appInfo.Path.Root util.RootPath = appInfo.Path.Root
util.CwdPath = appInfo.Path.Cwd
configResponse, err := httpClient.PostConfigGetWithResponse(ctx) configInfo, err := httpClient.Config.Get(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if configResponse.StatusCode() != 200 || configResponse.JSON200 == nil {
return nil, fmt.Errorf("failed to get config: %d", configResponse.StatusCode()) if configInfo.Keybinds.Leader == "" {
} configInfo.Keybinds.Leader = "ctrl+x"
configInfo := configResponse.JSON200
if configInfo.Keybinds == nil {
leader := "ctrl+x"
keybinds := client.ConfigKeybinds{
Leader: &leader,
}
configInfo.Keybinds = &keybinds
} }
appStatePath := filepath.Join(appInfo.Path.State, "tui") appStatePath := filepath.Join(appInfo.Path.State, "tui")
@@ -84,16 +81,16 @@ func New(
config.SaveState(appStatePath, appState) config.SaveState(appStatePath, appState)
} }
if configInfo.Theme != nil { if configInfo.Theme != "" {
appState.Theme = *configInfo.Theme appState.Theme = configInfo.Theme
} }
if configInfo.Model != nil {
splits := strings.Split(*configInfo.Model, "/") if configInfo.Model != "" {
splits := strings.Split(configInfo.Model, "/")
appState.Provider = splits[0] appState.Provider = splits[0]
appState.Model = strings.Join(splits[1:], "/") appState.Model = strings.Join(splits[1:], "/")
} }
// Load themes from all directories
if err := theme.LoadThemesFromDirectories( if err := theme.LoadThemesFromDirectories(
appInfo.Path.Config, appInfo.Path.Config,
appInfo.Path.Root, appInfo.Path.Root,
@@ -103,6 +100,12 @@ func New(
} }
if appState.Theme != "" { if appState.Theme != "" {
if appState.Theme == "system" && styles.Terminal != nil {
theme.UpdateSystemTheme(
styles.Terminal.Background,
styles.Terminal.BackgroundIsDark,
)
}
theme.SetTheme(appState.Theme) theme.SetTheme(appState.Theme)
} }
@@ -115,33 +118,42 @@ func New(
Config: configInfo, Config: configInfo,
State: appState, State: appState,
Client: httpClient, Client: httpClient,
Session: &client.SessionInfo{}, Session: &opencode.Session{},
Messages: []client.MessageInfo{}, Messages: []opencode.Message{},
Commands: commands.LoadFromConfig(configInfo), Commands: commands.LoadFromConfig(configInfo),
} }
return app, nil return app, nil
} }
func (a *App) Key(commandName commands.CommandName) string {
t := theme.CurrentTheme()
base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
muted := styles.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Faint(true).Render
command := a.Commands[commandName]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = a.Config.Keybinds.Leader + " " + kb.Key
}
return base(key) + muted(" "+command.Description)
}
func (a *App) InitializeProvider() tea.Cmd { func (a *App) InitializeProvider() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
providersResponse, err := a.Client.PostProviderListWithResponse(context.Background()) providersResponse, err := a.Client.Config.Providers(context.Background())
if err != nil { if err != nil {
slog.Error("Failed to list providers", "error", err) slog.Error("Failed to list providers", "error", err)
// TODO: notify user // TODO: notify user
return nil return nil
} }
if providersResponse != nil && providersResponse.StatusCode() != 200 { providers := providersResponse.Providers
slog.Error("failed to retrieve providers", "status", providersResponse.StatusCode(), "message", string(providersResponse.Body)) var defaultProvider *opencode.Provider
return nil var defaultModel *opencode.Model
}
providers := []client.ProviderInfo{}
var defaultProvider *client.ProviderInfo
var defaultModel *client.ModelInfo
var anthropic *client.ProviderInfo var anthropic *opencode.Provider
for _, provider := range providersResponse.JSON200.Providers { for _, provider := range providers {
if provider.Id == "anthropic" { if provider.ID == "anthropic" {
anthropic = &provider anthropic = &provider
} }
} }
@@ -152,7 +164,7 @@ func (a *App) InitializeProvider() tea.Cmd {
defaultModel = getDefaultModel(providersResponse, *anthropic) defaultModel = getDefaultModel(providersResponse, *anthropic)
} }
for _, provider := range providersResponse.JSON200.Providers { for _, provider := range providers {
if defaultProvider == nil || defaultModel == nil { if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider) defaultModel = getDefaultModel(providersResponse, provider)
@@ -164,14 +176,14 @@ func (a *App) InitializeProvider() tea.Cmd {
return nil return nil
} }
var currentProvider *client.ProviderInfo var currentProvider *opencode.Provider
var currentModel *client.ModelInfo var currentModel *opencode.Model
for _, provider := range providers { for _, provider := range providers {
if provider.Id == a.State.Provider { if provider.ID == a.State.Provider {
currentProvider = &provider currentProvider = &provider
for _, model := range provider.Models { for _, model := range provider.Models {
if model.Id == a.State.Model { if model.ID == a.State.Model {
currentModel = &model currentModel = &model
} }
} }
@@ -182,7 +194,6 @@ func (a *App) InitializeProvider() tea.Cmd {
currentModel = defaultModel currentModel = defaultModel
} }
// TODO: handle no provider or model setup, yet
return ModelSelectedMsg{ return ModelSelectedMsg{
Provider: *currentProvider, Provider: *currentProvider,
Model: *currentModel, Model: *currentModel,
@@ -190,8 +201,8 @@ func (a *App) InitializeProvider() tea.Cmd {
} }
} }
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo { func getDefaultModel(response *opencode.ConfigProvidersResponse, provider opencode.Provider) *opencode.Model {
if match, ok := response.JSON200.Default[provider.Id]; ok { if match, ok := response.Default[provider.ID]; ok {
model := provider.Models[match] model := provider.Models[match]
return &model return &model
} else { } else {
@@ -215,7 +226,7 @@ func (a *App) IsBusy() bool {
} }
lastMessage := a.Messages[len(a.Messages)-1] lastMessage := a.Messages[len(a.Messages)-1]
return lastMessage.Metadata.Time.Completed == nil return lastMessage.Metadata.Time.Completed == 0
} }
func (a *App) SaveState() { func (a *App) SaveState() {
@@ -238,19 +249,14 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session))) cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
go func() { go func() {
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{ _, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
SessionID: a.Session.Id, ProviderID: opencode.F(a.Provider.ID),
ProviderID: a.Provider.Id, ModelID: opencode.F(a.Model.ID),
ModelID: a.Model.Id,
}) })
if err != nil { if err != nil {
slog.Error("Failed to initialize project", "error", err) slog.Error("Failed to initialize project", "error", err)
// status.Error(err.Error()) // status.Error(err.Error())
} }
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to initialize project", "error", response.StatusCode)
// status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
}
}() }()
return tea.Batch(cmds...) return tea.Batch(cmds...)
@@ -258,48 +264,37 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
func (a *App) CompactSession(ctx context.Context) tea.Cmd { func (a *App) CompactSession(ctx context.Context) tea.Cmd {
go func() { go func() {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{ _, err := a.Client.Session.Summarize(ctx, a.Session.ID, opencode.SessionSummarizeParams{
SessionID: a.Session.Id, ProviderID: opencode.F(a.Provider.ID),
ProviderID: a.Provider.Id, ModelID: opencode.F(a.Model.ID),
ModelID: a.Model.Id,
}) })
if err != nil { if err != nil {
slog.Error("Failed to compact session", "error", err) 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 return nil
} }
func (a *App) MarkProjectInitialized(ctx context.Context) error { func (a *App) MarkProjectInitialized(ctx context.Context) error {
response, err := a.Client.PostAppInitialize(ctx) _, err := a.Client.App.Init(ctx)
if err != nil { if err != nil {
slog.Error("Failed to mark project as initialized", "error", err) slog.Error("Failed to mark project as initialized", "error", err)
return err return err
} }
if response != nil && response.StatusCode != 200 {
return fmt.Errorf("failed to initialize project: %d", response.StatusCode)
}
return nil return nil
} }
func (a *App) CreateSession(ctx context.Context) (*client.SessionInfo, error) { func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
resp, err := a.Client.PostSessionCreateWithResponse(ctx) session, err := a.Client.Session.New(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp != nil && resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to create session: %d", resp.StatusCode())
}
session := resp.JSON200
return session, nil return session, nil
} }
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd { func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
var cmds []tea.Cmd var cmds []tea.Cmd
if a.Session.Id == "" { if a.Session.ID == "" {
session, err := a.CreateSession(ctx) session, err := a.CreateSession(ctx)
if err != nil { if err != nil {
return toast.NewErrorToast(err.Error()) return toast.NewErrorToast(err.Error())
@@ -308,26 +303,18 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session))) cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
} }
part := client.MessagePart{} optimisticMessage := opencode.Message{
part.FromMessagePartText(client.MessagePartText{ ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Type: "text", Role: opencode.MessageRoleUser,
Text: text, Parts: []opencode.MessagePart{{
}) Type: opencode.MessagePartTypeText,
parts := []client.MessagePart{part} Text: text,
}},
optimisticMessage := client.MessageInfo{ Metadata: opencode.MessageMetadata{
Id: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), SessionID: a.Session.ID,
Role: client.User, Time: opencode.MessageMetadataTime{
Parts: parts, Created: float64(time.Now().Unix()),
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),
}, },
} }
@@ -335,22 +322,21 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage})) cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
response, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{ _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
SessionID: a.Session.Id, Parts: opencode.F([]opencode.MessagePartUnionParam{
Parts: parts, opencode.TextPartParam{
ProviderID: a.Provider.Id, Type: opencode.F(opencode.TextPartTypeText),
ModelID: a.Model.Id, Text: opencode.F(text),
},
}),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
}) })
if err != nil { if err != nil {
errormsg := fmt.Sprintf("failed to send message: %v", err) errormsg := fmt.Sprintf("failed to send message: %v", err)
slog.Error(errormsg) slog.Error(errormsg)
return toast.NewErrorToast(errormsg)() return toast.NewErrorToast(errormsg)()
} }
if response != nil && response.StatusCode != 200 {
errormsg := fmt.Sprintf("failed to send message: %d", response.StatusCode)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
return nil return nil
}) })
@@ -360,83 +346,61 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
} }
func (a *App) Cancel(ctx context.Context, sessionID string) error { func (a *App) Cancel(ctx context.Context, sessionID string) error {
response, err := a.Client.PostSessionAbort(ctx, client.PostSessionAbortJSONRequestBody{ _, err := a.Client.Session.Abort(ctx, sessionID)
SessionID: sessionID,
})
if err != nil { if err != nil {
slog.Error("Failed to cancel session", "error", err) slog.Error("Failed to cancel session", "error", err)
// status.Error(err.Error()) // status.Error(err.Error())
return err return err
} }
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to cancel session", "error", fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
// status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
}
return nil return nil
} }
func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) { func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
resp, err := a.Client.PostSessionListWithResponse(ctx) response, err := a.Client.Session.List(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp.StatusCode() != 200 { if response == nil {
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode()) return []opencode.Session{}, nil
} }
if resp.JSON200 == nil { sessions := *response
return []client.SessionInfo{}, nil
}
sessions := *resp.JSON200
sort.Slice(sessions, func(i, j int) bool { sort.Slice(sessions, func(i, j int) bool {
return sessions[i].Time.Created-sessions[j].Time.Created > 0 return sessions[i].Time.Created-sessions[j].Time.Created > 0
}) })
return sessions, nil return sessions, nil
} }
func (a *App) DeleteSession(ctx context.Context, sessionID string) error { func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{ _, err := a.Client.Session.Delete(ctx, sessionID)
SessionID: sessionID,
})
if err != nil { if err != nil {
slog.Error("Failed to delete session", "error", err)
return err return err
} }
if resp.StatusCode() != 200 {
return fmt.Errorf("failed to delete session: %d", resp.StatusCode())
}
return nil return nil
} }
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) { func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) {
resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId}) response, err := a.Client.Session.Messages(ctx, sessionId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp.StatusCode() != 200 { if response == nil {
return nil, fmt.Errorf("failed to list messages: %d", resp.StatusCode()) return []opencode.Message{}, nil
} }
if resp.JSON200 == nil { messages := *response
return []client.MessageInfo{}, nil
}
messages := *resp.JSON200
return messages, nil return messages, nil
} }
func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error) { func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
resp, err := a.Client.PostProviderListWithResponse(ctx) response, err := a.Client.Config.Providers(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp.StatusCode() != 200 { if response == nil {
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode()) return []opencode.Provider{}, nil
}
if resp.JSON200 == nil {
return []client.ProviderInfo{}, nil
} }
providers := *resp.JSON200 providers := *response
return providers.Providers, nil return providers.Providers, nil
} }
+49 -18
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/pkg/client" "github.com/sst/opencode-sdk-go"
) )
type ExecuteCommandMsg Command type ExecuteCommandMsg Command
@@ -80,13 +80,15 @@ const (
ToolDetailsCommand CommandName = "tool_details" ToolDetailsCommand CommandName = "tool_details"
ModelListCommand CommandName = "model_list" ModelListCommand CommandName = "model_list"
ThemeListCommand CommandName = "theme_list" ThemeListCommand CommandName = "theme_list"
FileListCommand CommandName = "file_list"
FileCloseCommand CommandName = "file_close"
FileSearchCommand CommandName = "file_search"
FileDiffToggleCommand CommandName = "file_diff_toggle"
ProjectInitCommand CommandName = "project_init" ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear" InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste" InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit" InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline" InputNewlineCommand CommandName = "input_newline"
HistoryPreviousCommand CommandName = "history_previous"
HistoryNextCommand CommandName = "history_next"
MessagesPageUpCommand CommandName = "messages_page_up" MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down" MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up" MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
@@ -95,6 +97,9 @@ const (
MessagesNextCommand CommandName = "messages_next" MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first" MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last" MessagesLastCommand CommandName = "messages_last"
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
MessagesCopyCommand CommandName = "messages_copy"
MessagesRevertCommand CommandName = "messages_revert"
AppExitCommand CommandName = "app_exit" AppExitCommand CommandName = "app_exit"
) )
@@ -123,7 +128,7 @@ func parseBindings(bindings ...string) []Keybinding {
return parsedBindings return parsedBindings
} }
func LoadFromConfig(config *client.ConfigInfo) CommandRegistry { func LoadFromConfig(config *opencode.Config) CommandRegistry {
defaults := []Command{ defaults := []Command{
{ {
Name: AppHelpCommand, Name: AppHelpCommand,
@@ -184,6 +189,27 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
Keybindings: parseBindings("<leader>t"), Keybindings: parseBindings("<leader>t"),
Trigger: "themes", Trigger: "themes",
}, },
{
Name: FileListCommand,
Description: "list files",
Keybindings: parseBindings("<leader>f"),
Trigger: "files",
},
{
Name: FileCloseCommand,
Description: "close file",
Keybindings: parseBindings("esc"),
},
{
Name: FileSearchCommand,
Description: "search file",
Keybindings: parseBindings("<leader>/"),
},
{
Name: FileDiffToggleCommand,
Description: "split/unified diff",
Keybindings: parseBindings("<leader>v"),
},
{ {
Name: ProjectInitCommand, Name: ProjectInitCommand,
Description: "create/update AGENTS.md", Description: "create/update AGENTS.md",
@@ -210,16 +236,6 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
Description: "insert newline", Description: "insert newline",
Keybindings: parseBindings("shift+enter", "ctrl+j"), Keybindings: parseBindings("shift+enter", "ctrl+j"),
}, },
// {
// Name: HistoryPreviousCommand,
// Description: "previous prompt",
// Keybindings: parseBindings("up"),
// },
// {
// Name: HistoryNextCommand,
// Description: "next prompt",
// Keybindings: parseBindings("down"),
// },
{ {
Name: MessagesPageUpCommand, Name: MessagesPageUpCommand,
Description: "page up", Description: "page up",
@@ -243,12 +259,12 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
{ {
Name: MessagesPreviousCommand, Name: MessagesPreviousCommand,
Description: "previous message", Description: "previous message",
Keybindings: parseBindings("ctrl+alt+k"), Keybindings: parseBindings("ctrl+up"),
}, },
{ {
Name: MessagesNextCommand, Name: MessagesNextCommand,
Description: "next message", Description: "next message",
Keybindings: parseBindings("ctrl+alt+j"), Keybindings: parseBindings("ctrl+down"),
}, },
{ {
Name: MessagesFirstCommand, Name: MessagesFirstCommand,
@@ -260,6 +276,21 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
Description: "last message", Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"), Keybindings: parseBindings("ctrl+alt+g"),
}, },
{
Name: MessagesLayoutToggleCommand,
Description: "toggle layout",
Keybindings: parseBindings("<leader>p"),
},
{
Name: MessagesCopyCommand,
Description: "copy message",
Keybindings: parseBindings("<leader>y"),
},
{
Name: MessagesRevertCommand,
Description: "revert message",
Keybindings: parseBindings("<leader>u"),
},
{ {
Name: AppExitCommand, Name: AppExitCommand,
Description: "exit the app", Description: "exit the app",
@@ -269,10 +300,10 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
} }
registry := make(CommandRegistry) registry := make(CommandRegistry)
keybinds := map[string]string{} keybinds := map[string]string{}
marshalled, _ := json.Marshal(*config.Keybinds) marshalled, _ := json.Marshal(config.Keybinds)
json.Unmarshal(marshalled, &keybinds) json.Unmarshal(marshalled, &keybinds)
for _, command := range defaults { for _, command := range defaults {
if keybind, ok := keybinds[string(command.Name)]; ok { if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
command.Keybindings = parseBindings(keybind) command.Keybindings = parseBindings(keybind)
} }
registry[command.Name] = command registry[command.Name] = command
@@ -9,6 +9,7 @@ import (
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
) )
@@ -24,20 +25,13 @@ func (c *CommandCompletionProvider) GetId() string {
return "commands" return "commands"
} }
func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Commands",
Value: "commands",
})
}
func (c *CommandCompletionProvider) GetEmptyMessage() string { func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands" return "no matching commands"
} }
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI { func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
spacer := strings.Repeat(" ", space) spacer := strings.Repeat(" ", space)
title := " /" + cmd.Trigger + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description) title := " /" + cmd.Trigger + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
value := string(cmd.Name) value := string(cmd.Name)
return dialog.NewCompletionItem(dialog.CompletionItem{ return dialog.NewCompletionItem(dialog.CompletionItem{
Title: title, Title: title,
@@ -2,67 +2,108 @@ package completions
import ( import (
"context" "context"
"log/slog"
"sort"
"strconv"
"strings"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/pkg/client" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
) )
type filesAndFoldersContextGroup struct { type filesAndFoldersContextGroup struct {
app *app.App app *app.App
prefix string prefix string
gitFiles []dialog.CompletionItemI
} }
func (cg *filesAndFoldersContextGroup) GetId() string { func (cg *filesAndFoldersContextGroup) GetId() string {
return cg.prefix return cg.prefix
} }
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Files & Folders",
Value: "files",
})
}
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string { func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
return "no matching files" return "no matching files"
} }
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{ t := theme.CurrentTheme()
Query: query, items := make([]dialog.CompletionItemI, 0)
}) base := styles.NewStyle().Background(t.BackgroundElement())
if err != nil { green := base.Foreground(t.Success()).Render
return []string{}, err red := base.Foreground(t.Error()).Render
}
if response.JSON200 == nil { status, _ := cg.app.Client.File.Status(context.Background())
return []string{}, nil if status != nil {
files := *status
sort.Slice(files, func(i, j int) bool {
return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed
})
for _, file := range files {
title := file.File
if file.Added > 0 {
title += green(" +" + strconv.Itoa(int(file.Added)))
}
if file.Removed > 0 {
title += red(" -" + strconv.Itoa(int(file.Removed)))
}
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: file.File,
})
items = append(items, item)
}
} }
return *response.JSON200, nil return items
} }
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
matches, err := cg.getFiles(query) items := make([]dialog.CompletionItemI, 0)
if err != nil {
return nil, err query = strings.TrimSpace(query)
if query == "" {
items = append(items, cg.gitFiles...)
} }
items := make([]dialog.CompletionItemI, 0, len(matches)) files, err := cg.app.Client.Find.Files(
for _, file := range matches { context.Background(),
item := dialog.NewCompletionItem(dialog.CompletionItem{ opencode.FindFilesParams{Query: opencode.F(query)},
Title: file, )
Value: file, if err != nil {
}) slog.Error("Failed to get completion items", "error", err)
items = append(items, item) }
for _, file := range *files {
exists := false
for _, existing := range cg.gitFiles {
if existing.GetValue() == file {
if query != "" {
items = append(items, existing)
}
exists = true
}
}
if !exists {
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: file,
Value: file,
})
items = append(items, item)
}
} }
return items, nil return items, nil
} }
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider { func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
return &filesAndFoldersContextGroup{ cg := &filesAndFoldersContextGroup{
app: app, app: app,
prefix: "file", prefix: "file",
} }
cg.gitFiles = cg.getGitFiles()
return cg
} }
+67 -106
View File
@@ -13,7 +13,6 @@ import (
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea" "github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/image" "github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
@@ -21,34 +20,30 @@ import (
type EditorComponent interface { type EditorComponent interface {
tea.Model tea.Model
tea.ViewModel View(width int) string
layout.Sizeable Content(width int) string
Content() string
Lines() int Lines() int
Value() string Value() string
Focused() bool
Focus() (tea.Model, tea.Cmd)
Blur()
Submit() (tea.Model, tea.Cmd) Submit() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd) Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd) Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd) Newline() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd)
SetInterruptKeyInDebounce(inDebounce bool) SetInterruptKeyInDebounce(inDebounce bool)
} }
type editorComponent struct { type editorComponent struct {
app *app.App app *app.App
width, height int
textarea textarea.Model textarea textarea.Model
attachments []app.Attachment attachments []app.Attachment
history []string
historyIndex int
currentMessage string
spinner spinner.Model spinner spinner.Model
interruptKeyInDebounce bool interruptKeyInDebounce bool
} }
func (m *editorComponent) Init() tea.Cmd { func (m *editorComponent) Init() tea.Cmd {
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus) return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
} }
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -69,7 +64,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.ThemeSelectedMsg: case dialog.ThemeSelectedMsg:
m.textarea = createTextArea(&m.textarea) m.textarea = createTextArea(&m.textarea)
m.spinner = createSpinner() m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, textarea.Blink) return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg: case dialog.CompletionSelectedMsg:
if msg.IsCommand { if msg.IsCommand {
commandName := strings.TrimPrefix(msg.CompletionValue, "/") commandName := strings.TrimPrefix(msg.CompletionValue, "/")
@@ -80,8 +75,15 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} else { } else {
existingValue := m.textarea.Value() existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue + " ") // Replace the current token (after last space)
lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 {
m.textarea.SetValue(msg.CompletionValue + " ")
} else {
modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
m.textarea.SetValue(modifiedValue + " ")
}
return m, nil return m, nil
} }
} }
@@ -95,26 +97,31 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
func (m *editorComponent) Content() string { func (m *editorComponent) Content(width int) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
base := styles.BaseStyle().Background(t.Background()).Render base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
promptStyle := lipgloss.NewStyle(). promptStyle := styles.NewStyle().Foreground(t.Primary()).
Padding(0, 0, 0, 1). Padding(0, 0, 0, 1).
Bold(true). Bold(true)
Foreground(t.Primary())
prompt := promptStyle.Render(">") prompt := promptStyle.Render(">")
m.textarea.SetWidth(width - 6)
textarea := lipgloss.JoinHorizontal( textarea := lipgloss.JoinHorizontal(
lipgloss.Top, lipgloss.Top,
prompt, prompt,
m.textarea.View(), m.textarea.View(),
) )
textarea = styles.BaseStyle(). textarea = styles.NewStyle().
Width(m.width). Background(t.BackgroundElement()).
Width(width).
PaddingTop(1). PaddingTop(1).
PaddingBottom(1). PaddingBottom(1).
Background(t.BackgroundElement()). BorderStyle(lipgloss.ThickBorder()).
BorderForeground(t.Border()).
BorderBackground(t.Background()).
BorderLeft(true).
BorderRight(true).
Render(textarea) Render(textarea)
hint := base(m.getSubmitKeyText()) + muted(" send ") hint := base(m.getSubmitKeyText()) + muted(" send ")
@@ -132,33 +139,40 @@ func (m *editorComponent) Content() string {
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name) model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
} }
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
spacer := lipgloss.NewStyle().Background(t.Background()).Width(space).Render("") spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
info := hint + spacer + model info := hint + spacer + model
info = styles.Padded().Background(t.Background()).Render(info) info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
content := strings.Join([]string{"", textarea, info}, "\n") content := strings.Join([]string{"", textarea, info}, "\n")
return content return content
} }
func (m *editorComponent) View() string { func (m *editorComponent) View(width int) string {
if m.Lines() > 1 { if m.Lines() > 1 {
return "" return lipgloss.Place(
width,
5,
lipgloss.Center,
lipgloss.Center,
"",
styles.WhitespaceStyle(theme.CurrentTheme().Background()),
)
} }
return m.Content() return m.Content(width)
} }
func (m *editorComponent) GetSize() (width, height int) { func (m *editorComponent) Focused() bool {
return m.width, m.height return m.textarea.Focused()
} }
func (m *editorComponent) SetSize(width, height int) tea.Cmd { func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
m.width = width return m, m.textarea.Focus()
m.height = height }
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
// m.textarea.SetHeight(height - 4) func (m *editorComponent) Blur() {
return nil m.textarea.Blur()
} }
func (m *editorComponent) Lines() int { func (m *editorComponent) Lines() int {
@@ -186,16 +200,6 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
attachments := m.attachments attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
if value != "" {
if len(m.history) == 0 || m.history[len(m.history)-1] != value {
m.history = append(m.history, value)
}
m.historyIndex = len(m.history)
m.currentMessage = ""
}
m.attachments = nil m.attachments = nil
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments})) cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
@@ -228,48 +232,6 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
return m, nil
}
func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
return m, nil
}
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) { func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce m.interruptKeyInDebounce = inDebounce
} }
@@ -290,14 +252,14 @@ func createTextArea(existing *textarea.Model) textarea.Model {
ta := textarea.New() ta := textarea.New()
ta.Styles.Blurred.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor) ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = lipgloss.NewStyle().Background(bgColor) ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Blurred.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor) ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor) ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor) ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.CursorLine = lipgloss.NewStyle().Background(bgColor) ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Focused.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor) ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor) ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Cursor.Color = t.Primary() ta.Styles.Cursor.Color = t.Primary()
ta.Prompt = " " ta.Prompt = " "
@@ -306,22 +268,24 @@ func createTextArea(existing *textarea.Model) textarea.Model {
if existing != nil { if existing != nil {
ta.SetValue(existing.Value()) ta.SetValue(existing.Value())
ta.SetWidth(existing.Width()) // ta.SetWidth(existing.Width())
ta.SetHeight(existing.Height()) ta.SetHeight(existing.Height())
} }
ta.Focus()
return ta return ta
} }
func createSpinner() spinner.Model { func createSpinner() spinner.Model {
t := theme.CurrentTheme()
return spinner.New( return spinner.New(
spinner.WithSpinner(spinner.Ellipsis), spinner.WithSpinner(spinner.Ellipsis),
spinner.WithStyle( spinner.WithStyle(
styles. styles.NewStyle().
Muted(). Background(t.Background()).
Background(theme.CurrentTheme().Background()). Foreground(t.TextMuted()).
Width(3)), Width(3).
Lipgloss(),
),
) )
} }
@@ -332,9 +296,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
return &editorComponent{ return &editorComponent{
app: app, app: app,
textarea: ta, textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
spinner: s, spinner: s,
interruptKeyInDebounce: false, interruptKeyInDebounce: false,
} }
File diff suppressed because it is too large Load Diff
+257 -277
View File
@@ -1,92 +1,114 @@
package chat package chat
import ( import (
"slices"
"strings" "strings"
"time"
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client" "github.com/sst/opencode/internal/util"
) )
type MessagesComponent interface { type MessagesComponent interface {
tea.Model tea.Model
tea.ViewModel View(width, height int) string
SetWidth(width int) tea.Cmd
PageUp() (tea.Model, tea.Cmd) PageUp() (tea.Model, tea.Cmd)
PageDown() (tea.Model, tea.Cmd) PageDown() (tea.Model, tea.Cmd)
HalfPageUp() (tea.Model, tea.Cmd) HalfPageUp() (tea.Model, tea.Cmd)
HalfPageDown() (tea.Model, tea.Cmd) HalfPageDown() (tea.Model, tea.Cmd)
First() (tea.Model, tea.Cmd) First() (tea.Model, tea.Cmd)
Last() (tea.Model, tea.Cmd) Last() (tea.Model, tea.Cmd)
// Previous() (tea.Model, tea.Cmd) Previous() (tea.Model, tea.Cmd)
// Next() (tea.Model, tea.Cmd) Next() (tea.Model, tea.Cmd)
ToolDetailsVisible() bool ToolDetailsVisible() bool
Selected() string
} }
type messagesComponent struct { type messagesComponent struct {
width, height int width int
app *app.App app *app.App
viewport viewport.Model viewport viewport.Model
spinner spinner.Model
attachments viewport.Model
commands commands.CommandsComponent
cache *MessageCache cache *MessageCache
rendering bool rendering bool
showToolDetails bool showToolDetails bool
tail bool tail bool
partCount int
lineCount int
selectedPart int
selectedText string
} }
type renderFinishedMsg struct{} type renderFinishedMsg struct{}
type selectedMessagePartChangedMsg struct {
part int
}
type ToggleToolDetailsMsg struct{} type ToggleToolDetailsMsg struct{}
func (m *messagesComponent) Init() tea.Cmd { func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.commands.Init()) return tea.Batch(m.viewport.Init())
}
func (m *messagesComponent) Selected() string {
return m.selectedText
} }
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg.(type) { switch msg := msg.(type) {
case app.SendMsg: case app.SendMsg:
m.viewport.GotoBottom() m.viewport.GotoBottom()
m.tail = true m.tail = true
m.selectedPart = -1
return m, nil return m, nil
case app.OptimisticMessageAddedMsg: case app.OptimisticMessageAddedMsg:
m.renderView() m.renderView(m.width)
if m.tail { if m.tail {
m.viewport.GotoBottom() m.viewport.GotoBottom()
} }
return m, nil return m, nil
case dialog.ThemeSelectedMsg: case dialog.ThemeSelectedMsg:
m.cache.Clear() m.cache.Clear()
m.rendering = true
return m, m.Reload() return m, m.Reload()
case ToggleToolDetailsMsg: case ToggleToolDetailsMsg:
m.showToolDetails = !m.showToolDetails m.showToolDetails = !m.showToolDetails
m.rendering = true
return m, m.Reload() return m, m.Reload()
case app.SessionSelectedMsg: case app.SessionLoadedMsg:
m.cache.Clear() m.cache.Clear()
m.tail = true m.tail = true
m.rendering = true
return m, m.Reload() return m, m.Reload()
case app.SessionClearedMsg: case app.SessionClearedMsg:
m.cache.Clear() m.cache.Clear()
cmd := m.Reload() m.rendering = true
return m, cmd return m, m.Reload()
case renderFinishedMsg: case renderFinishedMsg:
m.rendering = false m.rendering = false
if m.tail { if m.tail {
m.viewport.GotoBottom() m.viewport.GotoBottom()
} }
case client.EventSessionUpdated, client.EventMessageUpdated: case selectedMessagePartChangedMsg:
m.renderView() return m, m.Reload()
if m.tail { case opencode.EventListResponseEventSessionUpdated:
m.viewport.GotoBottom() if msg.Properties.Info.ID == m.app.Session.ID {
m.renderView(m.width)
if m.tail {
m.viewport.GotoBottom()
}
}
case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID {
m.renderView(m.width)
if m.tail {
m.viewport.GotoBottom()
}
} }
} }
@@ -95,187 +117,211 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tail = m.viewport.AtBottom() m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
updated, cmd := m.commands.Update(msg)
m.commands = updated.(commands.CommandsComponent)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
type blockType int func (m *messagesComponent) renderView(width int) {
measure := util.Measure("messages.renderView")
const ( defer measure("messageCount", len(m.app.Messages))
none blockType = iota
userTextBlock
assistantTextBlock
toolInvocationBlock
errorBlock
)
func (m *messagesComponent) renderView() {
if m.width == 0 {
return
}
t := theme.CurrentTheme() t := theme.CurrentTheme()
blocks := make([]string, 0) blocks := make([]string, 0)
previousBlockType := none m.partCount = 0
m.lineCount = 0
for _, message := range m.app.Messages { for _, message := range m.app.Messages {
var content string var content string
var cached bool var cached bool
lastToolIndex := 0
lastToolIndices := []int{}
for i, p := range message.Parts {
part, _ := p.ValueByDiscriminator()
switch part.(type) {
case client.MessagePartText:
lastToolIndices = append(lastToolIndices, lastToolIndex)
case client.MessagePartToolInvocation:
lastToolIndex = i
}
}
author := ""
switch message.Role { switch message.Role {
case client.User: case opencode.MessageRoleUser:
author = m.app.Info.User for _, part := range message.Parts {
case client.Assistant: switch part := part.AsUnion().(type) {
author = message.Metadata.Assistant.ModelID case opencode.TextPart:
} key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount)
for i, p := range message.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
// case client.MessagePartStepStart:
// messages = append(messages, "")
case client.MessagePartText:
text := part.(client.MessagePartText)
key := m.cache.GenerateKey(message.Id, text.Text, layout.Current.Viewport.Width)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(message, text.Text, author)
m.cache.Set(key, content)
}
if previousBlockType != none {
blocks = append(blocks, "")
}
blocks = append(blocks, content)
if message.Role == client.User {
previousBlockType = userTextBlock
} else if message.Role == client.Assistant {
previousBlockType = assistantTextBlock
}
case client.MessagePartToolInvocation:
isLastToolInvocation := slices.Contains(lastToolIndices, i)
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
metadata := client.MessageMetadata_Tool_AdditionalProperties{}
if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = message.Metadata.Tool[toolCall.ToolCallId]
}
var result *string
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
if resultError == nil {
result = &resultPart.Result
}
if toolCall.State == "result" {
key := m.cache.GenerateKey(message.Id,
toolCall.ToolCallId,
m.showToolDetails,
layout.Current.Viewport.Width,
)
content, cached = m.cache.Get(key) content, cached = m.cache.Get(key)
if !cached { if !cached {
content = renderToolInvocation( content = renderText(
toolCall, m.app,
result, message,
metadata, part.Text,
m.app.Info.User,
m.showToolDetails, m.showToolDetails,
isLastToolInvocation, m.partCount == m.selectedPart,
false, width,
) )
m.cache.Set(key, content) m.cache.Set(key, content)
} }
} else { if content != "" {
// if the tool call isn't finished, don't cache if m.selectedPart == m.partCount {
content = renderToolInvocation( m.viewport.SetYOffset(m.lineCount - 4)
toolCall, m.selectedText = part.Text
result, }
metadata, blocks = append(blocks, content)
m.showToolDetails, m.partCount++
isLastToolInvocation, m.lineCount += lipgloss.Height(content) + 1
false, }
)
} }
}
if previousBlockType != toolInvocationBlock && m.showToolDetails { case opencode.MessageRoleAssistant:
blocks = append(blocks, "") for i, p := range message.Parts {
switch part := p.AsUnion().(type) {
case opencode.TextPart:
finished := message.Metadata.Time.Completed > 0
remainingParts := message.Parts[i+1:]
toolCallParts := make([]opencode.ToolInvocationPart, 0)
for _, part := range remainingParts {
switch part := part.AsUnion().(type) {
case opencode.TextPart:
// we only want tool calls associated with the current text part.
// if we hit another text part, we're done.
break
case opencode.ToolInvocationPart:
toolCallParts = append(toolCallParts, part)
if part.ToolInvocation.State != "result" {
// i don't think there's a case where a tool call isn't in result state
// and the message time is 0, but just in case
finished = false
}
}
}
if finished {
key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
m.app,
message,
p.Text,
message.Metadata.Assistant.ModelID,
m.showToolDetails,
m.partCount == m.selectedPart,
width,
toolCallParts...,
)
m.cache.Set(key, content)
}
} else {
content = renderText(
m.app,
message,
p.Text,
message.Metadata.Assistant.ModelID,
m.showToolDetails,
m.partCount == m.selectedPart,
width,
toolCallParts...,
)
}
if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = p.Text
}
blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
}
case opencode.ToolInvocationPart:
if !m.showToolDetails {
continue
}
if part.ToolInvocation.State == "result" {
key := m.cache.GenerateKey(message.ID,
part.ToolInvocation.ToolCallID,
m.showToolDetails,
width,
m.partCount == m.selectedPart,
)
content, cached = m.cache.Get(key)
if !cached {
content = renderToolDetails(
m.app,
part,
message.Metadata,
m.partCount == m.selectedPart,
width,
)
m.cache.Set(key, content)
}
} else {
// if the tool call isn't finished, don't cache
content = renderToolDetails(
m.app,
part,
message.Metadata,
m.partCount == m.selectedPart,
width,
)
}
if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = ""
}
blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
}
} }
blocks = append(blocks, content)
previousBlockType = toolInvocationBlock
} }
} }
error := "" error := ""
if message.Metadata.Error != nil { switch err := message.Metadata.Error.AsUnion().(type) {
errorValue, _ := message.Metadata.Error.ValueByDiscriminator() case nil:
switch errorValue.(type) { case opencode.MessageMetadataErrorMessageOutputLengthError:
case client.UnknownError: error = "Message output length exceeded"
clientError := errorValue.(client.UnknownError) case opencode.ProviderAuthError:
error = clientError.Data.Message error = err.Data.Message
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1)) case opencode.UnknownError:
blocks = append(blocks, error) error = err.Data.Message
previousBlockType = errorBlock }
}
if error != "" {
error = renderContentBlock(
m.app,
error,
false,
width,
WithBorderColor(t.Error()),
)
blocks = append(blocks, error)
m.lineCount += lipgloss.Height(error) + 1
} }
} }
centered := []string{} m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n"))
for _, block := range blocks { if m.selectedPart == m.partCount-1 {
centered = append(centered, lipgloss.PlaceHorizontal( m.viewport.GotoBottom()
m.width,
lipgloss.Center,
block,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
))
} }
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()))
m.viewport.SetContent("\n" + strings.Join(centered, "\n") + "\n")
} }
func (m *messagesComponent) header() string { func (m *messagesComponent) header(width int) string {
if m.app.Session.Id == "" { if m.app.Session.ID == "" {
return "" return ""
} }
t := theme.CurrentTheme() t := theme.CurrentTheme()
width := layout.Current.Container.Width base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
base := styles.BaseStyle().Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render
headerLines := []string{} headerLines := []string{}
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background())) headerLines = append(headerLines, util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
if m.app.Session.Share != nil && m.app.Session.Share.Url != "" { if m.app.Session.Share.URL != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.Url)) headerLines = append(headerLines, muted(m.app.Session.Share.URL))
} else { } else {
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link")) headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
} }
header := strings.Join(headerLines, "\n") header := strings.Join(headerLines, "\n")
header = styles.BaseStyle(). header = styles.NewStyle().
Background(t.Background()).
Width(width). Width(width).
PaddingLeft(2). PaddingLeft(2).
PaddingRight(2). PaddingRight(2).
Background(t.Background()).
BorderLeft(true). BorderLeft(true).
BorderRight(true). BorderRight(true).
BorderBackground(t.Background()). BorderBackground(t.Background()).
@@ -286,98 +332,29 @@ func (m *messagesComponent) header() string {
return "\n" + header + "\n" return "\n" + header + "\n"
} }
func (m *messagesComponent) View() string { func (m *messagesComponent) View(width, height int) string {
if len(m.app.Messages) == 0 { t := theme.CurrentTheme()
return m.home()
}
if m.rendering { if m.rendering {
return lipgloss.Place( return lipgloss.Place(
m.width, width,
m.height, height,
lipgloss.Center, lipgloss.Center,
lipgloss.Center, lipgloss.Center,
"Loading session...", styles.NewStyle().Background(t.Background()).Render("Loading session..."),
styles.WhitespaceStyle(t.Background()),
) )
} }
t := theme.CurrentTheme() header := m.header(width)
return lipgloss.JoinVertical( m.viewport.SetWidth(width)
lipgloss.Left, m.viewport.SetHeight(height - lipgloss.Height(header))
lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.header(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
),
m.viewport.View(),
)
}
func (m *messagesComponent) home() string { return styles.NewStyle().
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().Background(t.Background())
base := baseStyle.Render
muted := styles.Muted().Background(t.Background()).Render
open := `
`
code := `
`
logo := lipgloss.JoinHorizontal(
lipgloss.Top,
muted(open),
base(code),
)
// cwd := app.Info.Path.Cwd
// config := app.Info.Path.Config
versionStyle := lipgloss.NewStyle().
Background(t.Background()). Background(t.Background()).
Foreground(t.TextMuted()). Render(header + "\n" + m.viewport.View())
Width(lipgloss.Width(logo)).
Align(lipgloss.Right)
version := versionStyle.Render(m.app.Version)
logoAndVersion := strings.Join([]string{logo, version}, "\n")
logoAndVersion = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
logoAndVersion,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
m.commands.SetBackgroundColor(t.Background())
commands := lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.commands.View(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
lines := []string{}
lines = append(lines, logoAndVersion)
lines = append(lines, "")
lines = append(lines, "")
// lines = append(lines, base("cwd ")+muted(cwd))
// lines = append(lines, base("config ")+muted(config))
// lines = append(lines, "")
lines = append(lines, commands)
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
baseStyle.Render(strings.Join(lines, "\n")),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
} }
func (m *messagesComponent) SetSize(width, height int) tea.Cmd { func (m *messagesComponent) SetWidth(width int) tea.Cmd {
if m.width == width && m.height == height { if m.width == width {
return nil return nil
} }
// Clear cache on resize since width affects rendering // Clear cache on resize since width affects rendering
@@ -385,24 +362,14 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
m.cache.Clear() m.cache.Clear()
} }
m.width = width m.width = width
m.height = height
m.viewport.SetWidth(width) m.viewport.SetWidth(width)
m.viewport.SetHeight(height - lipgloss.Height(m.header())) m.renderView(width)
m.attachments.SetWidth(width + 40)
m.attachments.SetHeight(3)
m.commands.SetSize(width, height)
m.renderView()
return nil return nil
} }
func (m *messagesComponent) GetSize() (int, int) {
return m.width, m.height
}
func (m *messagesComponent) Reload() tea.Cmd { func (m *messagesComponent) Reload() tea.Cmd {
m.rendering = true
return func() tea.Msg { return func() tea.Msg {
m.renderView() m.renderView(m.width)
return renderFinishedMsg{} return renderFinishedMsg{}
} }
} }
@@ -427,16 +394,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *messagesComponent) First() (tea.Model, tea.Cmd) { func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) {
m.viewport.GotoTop()
m.tail = false m.tail = false
return m, nil if m.selectedPart < 0 {
m.selectedPart = m.partCount
}
m.selectedPart--
if m.selectedPart < 0 {
m.selectedPart = 0
}
return m, util.CmdHandler(selectedMessagePartChangedMsg{
part: m.selectedPart,
})
}
func (m *messagesComponent) Next() (tea.Model, tea.Cmd) {
m.tail = false
m.selectedPart++
if m.selectedPart >= m.partCount {
m.selectedPart = m.partCount
}
return m, util.CmdHandler(selectedMessagePartChangedMsg{
part: m.selectedPart,
})
}
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
m.selectedPart = 0
m.tail = false
return m, util.CmdHandler(selectedMessagePartChangedMsg{
part: m.selectedPart,
})
} }
func (m *messagesComponent) Last() (tea.Model, tea.Cmd) { func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
m.viewport.GotoBottom() m.selectedPart = m.partCount - 1
m.tail = true m.tail = true
return m, nil return m, util.CmdHandler(selectedMessagePartChangedMsg{
part: m.selectedPart,
})
} }
func (m *messagesComponent) ToolDetailsVisible() bool { func (m *messagesComponent) ToolDetailsVisible() bool {
@@ -444,31 +440,15 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
} }
func NewMessagesComponent(app *app.App) MessagesComponent { func NewMessagesComponent(app *app.App) MessagesComponent {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
}
s := spinner.New(spinner.WithSpinner(customSpinner))
vp := viewport.New() vp := viewport.New()
attachments := viewport.New()
vp.KeyMap = viewport.KeyMap{} vp.KeyMap = viewport.KeyMap{}
t := theme.CurrentTheme()
commandsView := commands.New(
app,
commands.WithBackground(t.Background()),
commands.WithLimit(6),
)
return &messagesComponent{ return &messagesComponent{
app: app, app: app,
viewport: vp, viewport: vp,
spinner: s,
attachments: attachments,
commands: commandsView,
showToolDetails: true, showToolDetails: true,
cache: NewMessageCache(), cache: NewMessageCache(),
tail: true, tail: true,
selectedPart: -1,
} }
} }
@@ -9,15 +9,13 @@ import (
"github.com/charmbracelet/lipgloss/v2/compat" "github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
) )
type CommandsComponent interface { type CommandsComponent interface {
tea.Model
tea.ViewModel tea.ViewModel
layout.Sizeable SetSize(width, height int) tea.Cmd
SetBackgroundColor(color compat.AdaptiveColor) SetBackgroundColor(color compat.AdaptiveColor)
} }
@@ -36,39 +34,16 @@ func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
return nil return nil
} }
func (c *commandsComponent) GetSize() (int, int) {
return c.width, c.height
}
func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) { func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
c.background = &color c.background = &color
} }
func (c *commandsComponent) Init() tea.Cmd {
return nil
}
func (c *commandsComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
return c, nil
}
func (c *commandsComponent) View() string { func (c *commandsComponent) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
triggerStyle := lipgloss.NewStyle(). triggerStyle := styles.NewStyle().Foreground(t.Primary()).Bold(true)
Foreground(t.Primary()). descriptionStyle := styles.NewStyle().Foreground(t.Text())
Bold(true) keybindStyle := styles.NewStyle().Foreground(t.TextMuted())
descriptionStyle := lipgloss.NewStyle().
Foreground(t.Text())
keybindStyle := lipgloss.NewStyle().
Foreground(t.TextMuted())
if c.background != nil { if c.background != nil {
triggerStyle = triggerStyle.Background(*c.background) triggerStyle = triggerStyle.Background(*c.background)
@@ -99,10 +74,11 @@ func (c *commandsComponent) View() string {
} }
if len(commandsToShow) == 0 { if len(commandsToShow) == 0 {
muted := styles.NewStyle().Foreground(theme.CurrentTheme().TextMuted())
if c.showAll { if c.showAll {
return styles.Muted().Render("No commands available") return muted.Render("No commands available")
} }
return styles.Muted().Render("No commands with triggers available") return muted.Render("No commands with triggers available")
} }
// Calculate column widths // Calculate column widths
@@ -133,7 +109,7 @@ func (c *commandsComponent) View() string {
if c.showKeybinds { if c.showKeybinds {
for _, kb := range cmd.Keybindings { for _, kb := range cmd.Keybindings {
if kb.RequiresLeader { if kb.RequiresLeader {
keybindStrs = append(keybindStrs, *c.app.Config.Keybinds.Leader+" "+kb.Key) keybindStrs = append(keybindStrs, c.app.Config.Keybinds.Leader+" "+kb.Key)
} else { } else {
keybindStrs = append(keybindStrs, kb.Key) keybindStrs = append(keybindStrs, kb.Key)
} }
@@ -188,7 +164,7 @@ func (c *commandsComponent) View() string {
// Remove trailing newline // Remove trailing newline
result := strings.TrimSuffix(output.String(), "\n") result := strings.TrimSuffix(output.String(), "\n")
if c.background != nil { if c.background != nil {
result = lipgloss.NewStyle().Background(c.background).Width(maxWidth).Render(result) result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result)
} }
return result return result
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea" "github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
@@ -26,7 +27,7 @@ type CompletionItemI interface {
func (ci *CompletionItem) Render(selected bool, width int) string { func (ci *CompletionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.NewStyle().Foreground(t.Text())
itemStyle := baseStyle. itemStyle := baseStyle.
Background(t.BackgroundElement()). Background(t.BackgroundElement()).
@@ -34,14 +35,12 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
Padding(0, 1) Padding(0, 1)
if selected { if selected {
itemStyle = itemStyle. itemStyle = itemStyle.Foreground(t.Primary())
Foreground(t.Primary())
} }
title := itemStyle.Render( title := itemStyle.Render(
ci.DisplayValue(), ci.DisplayValue(),
) )
return title return title
} }
@@ -59,7 +58,6 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
type CompletionProvider interface { type CompletionProvider interface {
GetId() string GetId() string
GetEntry() CompletionItemI
GetChildEntries(query string) ([]CompletionItemI, error) GetChildEntries(query string) ([]CompletionItemI, error)
GetEmptyMessage() string GetEmptyMessage() string
} }
@@ -175,9 +173,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, c.pseudoSearchTextArea.Focus()) cmds = append(cmds, c.pseudoSearchTextArea.Focus())
return c, tea.Batch(cmds...) return c, tea.Batch(cmds...)
} }
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
} }
return c, tea.Batch(cmds...) return c, tea.Batch(cmds...)
@@ -185,7 +180,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (c *completionDialogComponent) View() string { func (c *completionDialogComponent) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.NewStyle().Foreground(t.Text())
maxWidth := 40 maxWidth := 40
completions := c.list.GetItems() completions := c.list.GetItems()
@@ -199,8 +194,14 @@ func (c *completionDialogComponent) View() string {
c.list.SetMaxWidth(maxWidth) c.list.SetMaxWidth(maxWidth)
return baseStyle.Padding(0, 0). return baseStyle.
Padding(0, 0).
Background(t.BackgroundElement()). Background(t.BackgroundElement()).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
BorderForeground(t.Border()).
BorderBackground(t.Background()).
Width(c.width). Width(c.width).
Render(c.list.View()) Render(c.list.View())
} }
@@ -0,0 +1,235 @@
package dialog
import (
"log/slog"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type FindSelectedMsg struct {
FilePath string
}
type FindDialogCloseMsg struct{}
type FindDialog interface {
layout.Modal
tea.Model
tea.ViewModel
SetWidth(width int)
SetHeight(height int)
IsEmpty() bool
SetProvider(provider CompletionProvider)
}
type findDialogComponent struct {
query string
completionProvider CompletionProvider
width, height int
modal *modal.Modal
textInput textinput.Model
list list.List[CompletionItemI]
}
type findDialogKeyMap struct {
Select key.Binding
Cancel key.Binding
}
var findDialogKeys = findDialogKeyMap{
Select: key.NewBinding(
key.WithKeys("enter"),
),
Cancel: key.NewBinding(
key.WithKeys("esc"),
),
}
func (f *findDialogComponent) Init() tea.Cmd {
return textinput.Blink
}
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case []CompletionItemI:
f.list.SetItems(msg)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
if f.textInput.Value() == "" {
return f, nil
}
f.textInput.SetValue("")
return f.update(msg)
}
switch {
case key.Matches(msg, findDialogKeys.Select):
item, i := f.list.GetSelectedItem()
if i == -1 {
return f, nil
}
return f, f.selectFile(item)
case key.Matches(msg, findDialogKeys.Cancel):
return f, f.Close()
default:
f.textInput, cmd = f.textInput.Update(msg)
cmds = append(cmds, cmd)
f, cmd = f.update(msg)
cmds = append(cmds, cmd)
}
}
return f, tea.Batch(cmds...)
}
func (f *findDialogComponent) update(msg tea.Msg) (*findDialogComponent, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
query := f.textInput.Value()
if query != f.query {
f.query = query
cmd = func() tea.Msg {
items, err := f.completionProvider.GetChildEntries(query)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
return items
}
cmds = append(cmds, cmd)
}
u, cmd := f.list.Update(msg)
f.list = u.(list.List[CompletionItemI])
cmds = append(cmds, cmd)
return f, tea.Batch(cmds...)
}
func (f *findDialogComponent) View() string {
t := theme.CurrentTheme()
f.textInput.SetWidth(f.width - 8)
f.list.SetMaxWidth(f.width - 4)
inputView := f.textInput.View()
inputView = styles.NewStyle().
Background(t.BackgroundPanel()).
Height(1).
Width(f.width-4).
Padding(0, 0).
Render(inputView)
listView := f.list.View()
return styles.NewStyle().Height(12).Render(inputView + "\n" + listView)
}
func (f *findDialogComponent) SetWidth(width int) {
f.width = width
if width > 4 {
f.textInput.SetWidth(width - 4)
f.list.SetMaxWidth(width - 4)
}
}
func (f *findDialogComponent) SetHeight(height int) {
f.height = height
}
func (f *findDialogComponent) IsEmpty() bool {
return f.list.IsEmpty()
}
func (f *findDialogComponent) SetProvider(provider CompletionProvider) {
f.completionProvider = provider
f.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
f.list.SetItems([]CompletionItemI{})
}
func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd {
return tea.Sequence(
f.Close(),
util.CmdHandler(FindSelectedMsg{
FilePath: item.GetValue(),
}),
)
}
func (f *findDialogComponent) Render(background string) string {
return f.modal.Render(f.View(), background)
}
func (f *findDialogComponent) Close() tea.Cmd {
f.textInput.Reset()
f.textInput.Blur()
return util.CmdHandler(modal.CloseModalMsg{})
}
func createTextInput(existing *textinput.Model) textinput.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundPanel()
textColor := t.Text()
textMutedColor := t.TextMuted()
ti := textinput.New()
ti.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ti.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ti.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ti.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ti.Styles.Cursor.Color = t.Primary()
ti.VirtualCursor = true
ti.Prompt = " "
ti.CharLimit = -1
ti.Focus()
if existing != nil {
ti.SetValue(existing.Value())
ti.SetWidth(existing.Width())
}
return ti
}
func NewFindDialog(completionProvider CompletionProvider) FindDialog {
ti := createTextInput(nil)
li := list.NewListComponent(
[]CompletionItemI{},
10, // max visible items
completionProvider.GetEmptyMessage(),
false,
)
// Load initial items
go func() {
items, err := completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
li.SetItems(items)
}()
return &findDialogComponent{
query: "",
completionProvider: completionProvider,
textInput: ti,
list: li,
modal: modal.New(
modal.WithTitle("Find Files"),
modal.WithMaxWidth(80),
),
}
}
@@ -20,10 +20,7 @@ type helpDialog struct {
} }
func (h *helpDialog) Init() tea.Cmd { func (h *helpDialog) Init() tea.Cmd {
return tea.Batch( return h.viewport.Init()
h.commandsComponent.Init(),
h.viewport.Init(),
)
} }
func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -38,10 +35,6 @@ func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
h.commandsComponent.SetSize(msg.Width-4, msg.Height-6) h.commandsComponent.SetSize(msg.Width-4, msg.Height-6)
} }
// Update commands component first to get the latest content
_, cmdCmd := h.commandsComponent.Update(msg)
cmds = append(cmds, cmdCmd)
// Update viewport content // Update viewport content
h.viewport.SetContent(h.commandsComponent.View()) h.viewport.SetContent(h.commandsComponent.View())
@@ -94,7 +94,7 @@ func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements tea.Model. // View implements tea.Model.
func (m InitDialogCmp) View() string { func (m InitDialogCmp) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.NewStyle().Foreground(t.Text())
// Calculate width needed for content // Calculate width needed for content
maxWidth := 60 // Width for explanation text maxWidth := 60 // Width for explanation text
+183 -136
View File
@@ -3,13 +3,12 @@ package dialog
import ( import (
"context" "context"
"fmt" "fmt"
"maps" "sort"
"slices" "time"
"strings"
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
@@ -17,12 +16,12 @@ import (
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
) )
const ( const (
numVisibleModels = 6 numVisibleModels = 10
maxDialogWidth = 40 minDialogWidth = 40
maxDialogWidth = 80
) )
// ModelDialog interface for the model selection dialog // ModelDialog interface for the model selection dialog
@@ -31,33 +30,61 @@ type ModelDialog interface {
} }
type modelDialog struct { type modelDialog struct {
app *app.App app *app.App
availableProviders []client.ProviderInfo allModels []ModelWithProvider
provider client.ProviderInfo width int
width int height int
height int modal *modal.Modal
hScrollOffset int modelList list.List[ModelItem]
hScrollPossible bool dialogWidth int
modal *modal.Modal }
modelList list.List[list.StringItem]
type ModelWithProvider struct {
Model opencode.Model
Provider opencode.Provider
}
type ModelItem struct {
ModelName string
ProviderName string
}
func (m ModelItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
if selected {
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
return styles.NewStyle().
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1).
Render(displayText)
} else {
modelStyle := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundElement())
providerStyle := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundElement())
modelPart := modelStyle.Render(m.ModelName)
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
combinedText := modelPart + providerPart
return styles.NewStyle().
Background(t.BackgroundElement()).
PaddingLeft(1).
Render(combinedText)
}
} }
type modelKeyMap struct { type modelKeyMap struct {
Left key.Binding
Right key.Binding
Enter key.Binding Enter key.Binding
Escape key.Binding Escape key.Binding
} }
var modelKeys = modelKeyMap{ var modelKeys = modelKeyMap{
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←", "scroll left"),
),
Right: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→", "scroll right"),
),
Enter: key.NewBinding( Enter: key.NewBinding(
key.WithKeys("enter"), key.WithKeys("enter"),
key.WithHelp("enter", "select model"), key.WithHelp("enter", "select model"),
@@ -69,7 +96,7 @@ var modelKeys = modelKeyMap{
} }
func (m *modelDialog) Init() tea.Cmd { func (m *modelDialog) Init() tea.Cmd {
m.setupModelsForProvider(m.provider.Id) m.setupAllModels()
return nil return nil
} }
@@ -77,34 +104,20 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
case key.Matches(msg, modelKeys.Left):
if m.hScrollPossible {
m.switchProvider(-1)
}
return m, nil
case key.Matches(msg, modelKeys.Right):
if m.hScrollPossible {
m.switchProvider(1)
}
return m, nil
case key.Matches(msg, modelKeys.Enter): case key.Matches(msg, modelKeys.Enter):
selectedItem, _ := m.modelList.GetSelectedItem() _, selectedIndex := m.modelList.GetSelectedItem()
models := m.models() if selectedIndex >= 0 && selectedIndex < len(m.allModels) {
var selectedModel client.ModelInfo selectedModel := m.allModels[selectedIndex]
for _, model := range models { return m, tea.Sequence(
if model.Name == string(selectedItem) { util.CmdHandler(modal.CloseModalMsg{}),
selectedModel = model util.CmdHandler(
break app.ModelSelectedMsg{
} Provider: selectedModel.Provider,
Model: selectedModel.Model,
}),
)
} }
return m, tea.Sequence( return m, util.CmdHandler(modal.CloseModalMsg{})
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(
app.ModelSelectedMsg{
Provider: m.provider,
Model: selectedModel,
}),
)
case key.Matches(msg, modelKeys.Escape): case key.Matches(msg, modelKeys.Escape):
return m, util.CmdHandler(modal.CloseModalMsg{}) return m, util.CmdHandler(modal.CloseModalMsg{})
} }
@@ -115,74 +128,124 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Update the list component // Update the list component
updatedList, cmd := m.modelList.Update(msg) updatedList, cmd := m.modelList.Update(msg)
m.modelList = updatedList.(list.List[list.StringItem]) m.modelList = updatedList.(list.List[ModelItem])
return m, cmd return m, cmd
} }
func (m *modelDialog) models() []client.ModelInfo {
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ModelInfo) int {
return strings.Compare(a.Name, b.Name)
})
return models
}
func (m *modelDialog) switchProvider(offset int) {
newOffset := m.hScrollOffset + offset
if newOffset < 0 {
newOffset = len(m.availableProviders) - 1
}
if newOffset >= len(m.availableProviders) {
newOffset = 0
}
m.hScrollOffset = newOffset
m.provider = m.availableProviders[m.hScrollOffset]
m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
m.setupModelsForProvider(m.provider.Id)
}
func (m *modelDialog) View() string { func (m *modelDialog) View() string {
listView := m.modelList.View() return m.modelList.View()
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
return strings.Join([]string{listView, scrollIndicator}, "\n")
} }
func (m *modelDialog) getScrollIndicators(maxWidth int) string { func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
var indicator string maxWidth := minDialogWidth
if m.hScrollPossible {
indicator = "← → (switch provider) "
}
if indicator == "" {
return ""
}
t := theme.CurrentTheme() for _, item := range modelItems {
return styles.BaseStyle(). // Calculate the width needed for this item: "ModelName (ProviderName)"
Foreground(t.TextMuted()). // Add 4 for the parentheses, space, and some padding
Width(maxWidth). itemWidth := len(item.ModelName) + len(item.ProviderName) + 4
Align(lipgloss.Right). if itemWidth > maxWidth {
Render(indicator) maxWidth = itemWidth
}
func (m *modelDialog) setupModelsForProvider(providerId string) {
models := m.models()
modelNames := make([]string, len(models))
for i, model := range models {
modelNames[i] = model.Name
}
m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true)
m.modelList.SetMaxWidth(maxDialogWidth)
if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.Id == providerId {
for i, model := range models {
if model.Id == m.app.Model.Id {
m.modelList.SetSelectedIndex(i)
break
}
} }
} }
if maxWidth > maxDialogWidth {
maxWidth = maxDialogWidth
}
return maxWidth
}
func (m *modelDialog) setupAllModels() {
providers, _ := m.app.ListProviders(context.Background())
m.allModels = make([]ModelWithProvider, 0)
for _, provider := range providers {
for _, model := range provider.Models {
m.allModels = append(m.allModels, ModelWithProvider{
Model: model,
Provider: provider,
})
}
}
m.sortModels()
modelItems := make([]ModelItem, len(m.allModels))
for i, modelWithProvider := range m.allModels {
modelItems[i] = ModelItem{
ModelName: modelWithProvider.Model.Name,
ProviderName: modelWithProvider.Provider.Name,
}
}
m.dialogWidth = m.calculateOptimalWidth(modelItems)
m.modelList = list.NewListComponent(modelItems, numVisibleModels, "No models available", true)
m.modelList.SetMaxWidth(m.dialogWidth)
if len(m.allModels) > 0 {
m.modelList.SetSelectedIndex(0)
}
}
func (m *modelDialog) sortModels() {
sort.Slice(m.allModels, func(i, j int) bool {
modelA := m.allModels[i]
modelB := m.allModels[j]
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
// If both have usage times, sort by most recent first
if !usageA.IsZero() && !usageB.IsZero() {
return usageA.After(usageB)
}
// If only one has usage time, it goes first
if !usageA.IsZero() && usageB.IsZero() {
return true
}
if usageA.IsZero() && !usageB.IsZero() {
return false
}
// If neither has usage time, sort by release date desc if available
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
if !dateA.IsZero() && !dateB.IsZero() {
return dateA.After(dateB)
}
}
// If only one has release date, it goes first
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
return true
}
if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
return false
}
// If neither has usage time nor release date, fall back to alphabetical sorting
return modelA.Model.Name < modelB.Model.Name
})
}
func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
return parsed
}
return time.Time{}
}
func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
for _, usage := range m.app.State.RecentlyUsedModels {
if usage.ProviderID == providerID && usage.ModelID == modelID {
return usage.LastUsed
}
}
return time.Time{}
} }
func (m *modelDialog) Render(background string) string { func (m *modelDialog) Render(background string) string {
@@ -194,32 +257,16 @@ func (s *modelDialog) Close() tea.Cmd {
} }
func NewModelDialog(app *app.App) ModelDialog { func NewModelDialog(app *app.App) ModelDialog {
availableProviders, _ := app.ListProviders(context.Background())
currentProvider := availableProviders[0]
hScrollOffset := 0
if app.Provider != nil {
for i, provider := range availableProviders {
if provider.Id == app.Provider.Id {
currentProvider = provider
hScrollOffset = i
break
}
}
}
dialog := &modelDialog{ dialog := &modelDialog{
app: app, app: app,
availableProviders: availableProviders,
hScrollOffset: hScrollOffset,
hScrollPossible: len(availableProviders) > 1,
provider: currentProvider,
modal: modal.New(
modal.WithTitle(fmt.Sprintf("Select %s Model", currentProvider.Name)),
modal.WithMaxWidth(maxDialogWidth+4),
),
} }
dialog.setupModelsForProvider(currentProvider.Id) dialog.setupAllModels()
dialog.modal = modal.New(
modal.WithTitle("Select Model"),
modal.WithMaxWidth(dialog.dialogWidth+4),
)
return dialog return dialog
} }
@@ -145,7 +145,7 @@ func (p *permissionDialogComponent) selectCurrentOption() tea.Cmd {
func (p *permissionDialogComponent) renderButtons() string { func (p *permissionDialogComponent) renderButtons() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.NewStyle().Foreground(t.Text())
allowStyle := baseStyle allowStyle := baseStyle
allowSessionStyle := baseStyle allowSessionStyle := baseStyle
@@ -355,8 +355,7 @@ func (p *permissionDialogComponent) renderDefaultContent() string {
func (p *permissionDialogComponent) styleViewport() string { func (p *permissionDialogComponent) styleViewport() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
contentStyle := lipgloss.NewStyle(). contentStyle := styles.NewStyle().Background(t.Background())
Background(t.Background())
return contentStyle.Render(p.contentViewPort.View()) return contentStyle.Render(p.contentViewPort.View())
} }
@@ -7,8 +7,8 @@ import (
"slices" "slices"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/muesli/reflow/truncate" "github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
@@ -17,7 +17,6 @@ import (
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
) )
// SessionDialog interface for the session switching dialog // SessionDialog interface for the session switching dialog
@@ -33,7 +32,7 @@ type sessionItem struct {
func (s sessionItem) Render(selected bool, width int) string { func (s sessionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.NewStyle()
var text string var text string
if s.isDeleteConfirming { if s.isDeleteConfirming {
@@ -44,20 +43,20 @@ func (s sessionItem) Render(selected bool, width int) string {
truncatedStr := truncate.StringWithTail(text, uint(width-1), "...") truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
var itemStyle lipgloss.Style var itemStyle styles.Style
if selected { if selected {
if s.isDeleteConfirming { if s.isDeleteConfirming {
// Red background for delete confirmation // Red background for delete confirmation
itemStyle = baseStyle. itemStyle = baseStyle.
Background(t.Error()). Background(t.Error()).
Foreground(t.Background()). Foreground(t.BackgroundElement()).
Width(width). Width(width).
PaddingLeft(1) PaddingLeft(1)
} else { } else {
// Normal selection // Normal selection
itemStyle = baseStyle. itemStyle = baseStyle.
Background(t.Primary()). Background(t.Primary()).
Foreground(t.Background()). Foreground(t.BackgroundElement()).
Width(width). Width(width).
PaddingLeft(1) PaddingLeft(1)
} }
@@ -80,7 +79,7 @@ type sessionDialog struct {
width int width int
height int height int
modal *modal.Modal modal *modal.Modal
sessions []client.SessionInfo sessions []opencode.Session
list list.List[sessionItem] list list.List[sessionItem]
app *app.App app *app.App
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
@@ -123,7 +122,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.updateListItems() s.updateListItems()
return nil return nil
}, },
s.deleteSession(sessionToDelete.Id), s.deleteSession(sessionToDelete.ID),
) )
} else { } else {
// First press - enter delete confirmation mode // First press - enter delete confirmation mode
@@ -151,9 +150,9 @@ func (s *sessionDialog) Render(background string) string {
listView := s.list.View() listView := s.list.View()
t := theme.CurrentTheme() t := theme.CurrentTheme()
helpStyle := styles.BaseStyle().PaddingLeft(1).PaddingTop(1) helpStyle := styles.NewStyle().PaddingLeft(1).PaddingTop(1)
helpText := styles.BaseStyle().Foreground(t.Text()).Render("x/del") helpText := styles.NewStyle().Foreground(t.Text()).Render("x/del")
helpText = helpText + styles.BaseStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session") helpText = helpText + styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session")
helpText = helpStyle.Render(helpText) helpText = helpStyle.Render(helpText)
content := strings.Join([]string{listView, helpText}, "\n") content := strings.Join([]string{listView, helpText}, "\n")
@@ -194,10 +193,10 @@ func (s *sessionDialog) Close() tea.Cmd {
func NewSessionDialog(app *app.App) SessionDialog { func NewSessionDialog(app *app.App) SessionDialog {
sessions, _ := app.ListSessions(context.Background()) sessions, _ := app.ListSessions(context.Background())
var filteredSessions []client.SessionInfo var filteredSessions []opencode.Session
var items []sessionItem var items []sessionItem
for _, sess := range sessions { for _, sess := range sessions {
if sess.ParentID != nil { if sess.ParentID != "" {
continue continue
} }
filteredSessions = append(filteredSessions, sess) filteredSessions = append(filteredSessions, sess)
@@ -103,7 +103,7 @@ func NewThemeDialog() ThemeDialog {
// Set the initial selection to the current theme // Set the initial selection to the current theme
list.SetSelectedIndex(selectedIdx) list.SetSelectedIndex(selectedIdx)
// Set the max width for the list to match the modal width // Set the max width for the list to match the modal width
list.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding) list.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
+211 -221
View File
@@ -1,6 +1,7 @@
package diff package diff
import ( import (
"bufio"
"bytes" "bytes"
"fmt" "fmt"
"image/color" "image/color"
@@ -8,6 +9,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters" "github.com/alecthomas/chroma/v2/formatters"
@@ -19,6 +21,7 @@ import (
"github.com/sergi/go-diff/diffmatchpatch" "github.com/sergi/go-diff/diffmatchpatch"
stylesi "github.com/sst/opencode/internal/styles" stylesi "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
) )
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -70,44 +73,6 @@ type linePair struct {
right *DiffLine right *DiffLine
} }
// -------------------------------------------------------------------------
// Side-by-Side Configuration
// -------------------------------------------------------------------------
// SideBySideConfig configures the rendering of side-by-side diffs
type SideBySideConfig struct {
TotalWidth int
}
// SideBySideOption modifies a SideBySideConfig
type SideBySideOption func(*SideBySideConfig)
// NewSideBySideConfig creates a SideBySideConfig with default values
func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
config := SideBySideConfig{
TotalWidth: 160, // Default width for side-by-side view
}
for _, opt := range opts {
opt(&config)
}
return config
}
// WithTotalWidth sets the total width for side-by-side view
func WithTotalWidth(width int) SideBySideOption {
return func(s *SideBySideConfig) {
if width > 0 {
s.TotalWidth = width
}
}
}
// -------------------------------------------------------------------------
// Unified Configuration
// -------------------------------------------------------------------------
// UnifiedConfig configures the rendering of unified diffs // UnifiedConfig configures the rendering of unified diffs
type UnifiedConfig struct { type UnifiedConfig struct {
Width int Width int
@@ -119,13 +84,22 @@ type UnifiedOption func(*UnifiedConfig)
// NewUnifiedConfig creates a UnifiedConfig with default values // NewUnifiedConfig creates a UnifiedConfig with default values
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig { func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
config := UnifiedConfig{ config := UnifiedConfig{
Width: 80, // Default width for unified view Width: 80,
} }
for _, opt := range opts { for _, opt := range opts {
opt(&config) opt(&config)
} }
return config
}
// NewSideBySideConfig creates a SideBySideConfig with default values
func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig {
config := UnifiedConfig{
Width: 160,
}
for _, opt := range opts {
opt(&config)
}
return config return config
} }
@@ -146,101 +120,87 @@ func WithWidth(width int) UnifiedOption {
func ParseUnifiedDiff(diff string) (DiffResult, error) { func ParseUnifiedDiff(diff string) (DiffResult, error) {
var result DiffResult var result DiffResult
var currentHunk *Hunk var currentHunk *Hunk
result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`) scanner := bufio.NewScanner(strings.NewReader(diff))
lines := strings.Split(diff, "\n")
var oldLine, newLine int var oldLine, newLine int
inFileHeader := true inFileHeader := true
for _, line := range lines { for scanner.Scan() {
// Parse file headers line := scanner.Text()
if inFileHeader { if inFileHeader {
if strings.HasPrefix(line, "--- a/") { if strings.HasPrefix(line, "--- a/") {
result.OldFile = strings.TrimPrefix(line, "--- a/") result.OldFile = line[6:]
continue continue
} }
if strings.HasPrefix(line, "+++ b/") { if strings.HasPrefix(line, "+++ b/") {
result.NewFile = strings.TrimPrefix(line, "+++ b/") result.NewFile = line[6:]
inFileHeader = false inFileHeader = false
continue continue
} }
} }
// Parse hunk headers if strings.HasPrefix(line, "@@") {
if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
if currentHunk != nil { if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk) result.Hunks = append(result.Hunks, *currentHunk)
} }
currentHunk = &Hunk{ currentHunk = &Hunk{
Header: line, Header: line,
Lines: []DiffLine{}, Lines: make([]DiffLine, 0, 10), // Pre-allocate
} }
oldStart, _ := strconv.Atoi(matches[1]) // Manual parsing of hunk header is faster than regex
newStart, _ := strconv.Atoi(matches[3]) parts := strings.Split(line, " ")
oldLine = oldStart if len(parts) > 2 {
newLine = newStart oldRange := strings.Split(parts[1][1:], ",")
newRange := strings.Split(parts[2][1:], ",")
oldLine, _ = strconv.Atoi(oldRange[0])
newLine, _ = strconv.Atoi(newRange[0])
}
continue continue
} }
// Ignore "No newline at end of file" markers if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
if strings.HasPrefix(line, "\\ No newline at end of file") {
continue continue
} }
if currentHunk == nil { var dl DiffLine
continue dl.Content = line
}
// Process the line based on its prefix
if len(line) > 0 { if len(line) > 0 {
switch line[0] { switch line[0] {
case '+': case '+':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{ dl.Kind = LineAdded
OldLineNo: 0, dl.NewLineNo = newLine
NewLineNo: newLine, dl.Content = line[1:]
Kind: LineAdded,
Content: line[1:],
})
newLine++ newLine++
case '-': case '-':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{ dl.Kind = LineRemoved
OldLineNo: oldLine, dl.OldLineNo = oldLine
NewLineNo: 0, dl.Content = line[1:]
Kind: LineRemoved,
Content: line[1:],
})
oldLine++ oldLine++
default: default: // context line
currentHunk.Lines = append(currentHunk.Lines, DiffLine{ dl.Kind = LineContext
OldLineNo: oldLine, dl.OldLineNo = oldLine
NewLineNo: newLine, dl.NewLineNo = newLine
Kind: LineContext,
Content: line,
})
oldLine++ oldLine++
newLine++ newLine++
} }
} else { } else { // empty context line
// Handle empty lines dl.Kind = LineContext
currentHunk.Lines = append(currentHunk.Lines, DiffLine{ dl.OldLineNo = oldLine
OldLineNo: oldLine, dl.NewLineNo = newLine
NewLineNo: newLine,
Kind: LineContext,
Content: "",
})
oldLine++ oldLine++
newLine++ newLine++
} }
currentHunk.Lines = append(currentHunk.Lines, dl)
} }
// Add the last hunk if there is one
if currentHunk != nil { if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk) result.Hunks = append(result.Hunks, *currentHunk)
} }
return result, nil return result, scanner.Err()
} }
// HighlightIntralineChanges updates lines in a hunk to show character-level differences // HighlightIntralineChanges updates lines in a hunk to show character-level differences
@@ -441,84 +401,84 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
<entry type="TextWhitespace" style="%s"/> <entry type="TextWhitespace" style="%s"/>
</style> </style>
`, `,
getColor(t.BackgroundPanel()), // Background getChromaColor(t.BackgroundPanel()), // Background
getColor(t.Text()), // Text getChromaColor(t.Text()), // Text
getColor(t.Text()), // Other getChromaColor(t.Text()), // Other
getColor(t.Error()), // Error getChromaColor(t.Error()), // Error
getColor(t.SyntaxKeyword()), // Keyword getChromaColor(t.SyntaxKeyword()), // Keyword
getColor(t.SyntaxKeyword()), // KeywordConstant getChromaColor(t.SyntaxKeyword()), // KeywordConstant
getColor(t.SyntaxKeyword()), // KeywordDeclaration getChromaColor(t.SyntaxKeyword()), // KeywordDeclaration
getColor(t.SyntaxKeyword()), // KeywordNamespace getChromaColor(t.SyntaxKeyword()), // KeywordNamespace
getColor(t.SyntaxKeyword()), // KeywordPseudo getChromaColor(t.SyntaxKeyword()), // KeywordPseudo
getColor(t.SyntaxKeyword()), // KeywordReserved getChromaColor(t.SyntaxKeyword()), // KeywordReserved
getColor(t.SyntaxType()), // KeywordType getChromaColor(t.SyntaxType()), // KeywordType
getColor(t.Text()), // Name getChromaColor(t.Text()), // Name
getColor(t.SyntaxVariable()), // NameAttribute getChromaColor(t.SyntaxVariable()), // NameAttribute
getColor(t.SyntaxType()), // NameBuiltin getChromaColor(t.SyntaxType()), // NameBuiltin
getColor(t.SyntaxVariable()), // NameBuiltinPseudo getChromaColor(t.SyntaxVariable()), // NameBuiltinPseudo
getColor(t.SyntaxType()), // NameClass getChromaColor(t.SyntaxType()), // NameClass
getColor(t.SyntaxVariable()), // NameConstant getChromaColor(t.SyntaxVariable()), // NameConstant
getColor(t.SyntaxFunction()), // NameDecorator getChromaColor(t.SyntaxFunction()), // NameDecorator
getColor(t.SyntaxVariable()), // NameEntity getChromaColor(t.SyntaxVariable()), // NameEntity
getColor(t.SyntaxType()), // NameException getChromaColor(t.SyntaxType()), // NameException
getColor(t.SyntaxFunction()), // NameFunction getChromaColor(t.SyntaxFunction()), // NameFunction
getColor(t.Text()), // NameLabel getChromaColor(t.Text()), // NameLabel
getColor(t.SyntaxType()), // NameNamespace getChromaColor(t.SyntaxType()), // NameNamespace
getColor(t.SyntaxVariable()), // NameOther getChromaColor(t.SyntaxVariable()), // NameOther
getColor(t.SyntaxKeyword()), // NameTag getChromaColor(t.SyntaxKeyword()), // NameTag
getColor(t.SyntaxVariable()), // NameVariable getChromaColor(t.SyntaxVariable()), // NameVariable
getColor(t.SyntaxVariable()), // NameVariableClass getChromaColor(t.SyntaxVariable()), // NameVariableClass
getColor(t.SyntaxVariable()), // NameVariableGlobal getChromaColor(t.SyntaxVariable()), // NameVariableGlobal
getColor(t.SyntaxVariable()), // NameVariableInstance getChromaColor(t.SyntaxVariable()), // NameVariableInstance
getColor(t.SyntaxString()), // Literal getChromaColor(t.SyntaxString()), // Literal
getColor(t.SyntaxString()), // LiteralDate getChromaColor(t.SyntaxString()), // LiteralDate
getColor(t.SyntaxString()), // LiteralString getChromaColor(t.SyntaxString()), // LiteralString
getColor(t.SyntaxString()), // LiteralStringBacktick getChromaColor(t.SyntaxString()), // LiteralStringBacktick
getColor(t.SyntaxString()), // LiteralStringChar getChromaColor(t.SyntaxString()), // LiteralStringChar
getColor(t.SyntaxString()), // LiteralStringDoc getChromaColor(t.SyntaxString()), // LiteralStringDoc
getColor(t.SyntaxString()), // LiteralStringDouble getChromaColor(t.SyntaxString()), // LiteralStringDouble
getColor(t.SyntaxString()), // LiteralStringEscape getChromaColor(t.SyntaxString()), // LiteralStringEscape
getColor(t.SyntaxString()), // LiteralStringHeredoc getChromaColor(t.SyntaxString()), // LiteralStringHeredoc
getColor(t.SyntaxString()), // LiteralStringInterpol getChromaColor(t.SyntaxString()), // LiteralStringInterpol
getColor(t.SyntaxString()), // LiteralStringOther getChromaColor(t.SyntaxString()), // LiteralStringOther
getColor(t.SyntaxString()), // LiteralStringRegex getChromaColor(t.SyntaxString()), // LiteralStringRegex
getColor(t.SyntaxString()), // LiteralStringSingle getChromaColor(t.SyntaxString()), // LiteralStringSingle
getColor(t.SyntaxString()), // LiteralStringSymbol getChromaColor(t.SyntaxString()), // LiteralStringSymbol
getColor(t.SyntaxNumber()), // LiteralNumber getChromaColor(t.SyntaxNumber()), // LiteralNumber
getColor(t.SyntaxNumber()), // LiteralNumberBin getChromaColor(t.SyntaxNumber()), // LiteralNumberBin
getColor(t.SyntaxNumber()), // LiteralNumberFloat getChromaColor(t.SyntaxNumber()), // LiteralNumberFloat
getColor(t.SyntaxNumber()), // LiteralNumberHex getChromaColor(t.SyntaxNumber()), // LiteralNumberHex
getColor(t.SyntaxNumber()), // LiteralNumberInteger getChromaColor(t.SyntaxNumber()), // LiteralNumberInteger
getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong getChromaColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
getColor(t.SyntaxNumber()), // LiteralNumberOct getChromaColor(t.SyntaxNumber()), // LiteralNumberOct
getColor(t.SyntaxOperator()), // Operator getChromaColor(t.SyntaxOperator()), // Operator
getColor(t.SyntaxKeyword()), // OperatorWord getChromaColor(t.SyntaxKeyword()), // OperatorWord
getColor(t.SyntaxPunctuation()), // Punctuation getChromaColor(t.SyntaxPunctuation()), // Punctuation
getColor(t.SyntaxComment()), // Comment getChromaColor(t.SyntaxComment()), // Comment
getColor(t.SyntaxComment()), // CommentHashbang getChromaColor(t.SyntaxComment()), // CommentHashbang
getColor(t.SyntaxComment()), // CommentMultiline getChromaColor(t.SyntaxComment()), // CommentMultiline
getColor(t.SyntaxComment()), // CommentSingle getChromaColor(t.SyntaxComment()), // CommentSingle
getColor(t.SyntaxComment()), // CommentSpecial getChromaColor(t.SyntaxComment()), // CommentSpecial
getColor(t.SyntaxKeyword()), // CommentPreproc getChromaColor(t.SyntaxKeyword()), // CommentPreproc
getColor(t.Text()), // Generic getChromaColor(t.Text()), // Generic
getColor(t.Error()), // GenericDeleted getChromaColor(t.Error()), // GenericDeleted
getColor(t.Text()), // GenericEmph getChromaColor(t.Text()), // GenericEmph
getColor(t.Error()), // GenericError getChromaColor(t.Error()), // GenericError
getColor(t.Text()), // GenericHeading getChromaColor(t.Text()), // GenericHeading
getColor(t.Success()), // GenericInserted getChromaColor(t.Success()), // GenericInserted
getColor(t.TextMuted()), // GenericOutput getChromaColor(t.TextMuted()), // GenericOutput
getColor(t.Text()), // GenericPrompt getChromaColor(t.Text()), // GenericPrompt
getColor(t.Text()), // GenericStrong getChromaColor(t.Text()), // GenericStrong
getColor(t.Text()), // GenericSubheading getChromaColor(t.Text()), // GenericSubheading
getColor(t.Error()), // GenericTraceback getChromaColor(t.Error()), // GenericTraceback
getColor(t.Text()), // TextWhitespace getChromaColor(t.Text()), // TextWhitespace
) )
r := strings.NewReader(syntaxThemeXml) r := strings.NewReader(syntaxThemeXml)
@@ -527,6 +487,9 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
// Modify the style to use the provided background // Modify the style to use the provided background
s, err := style.Builder().Transform( s, err := style.Builder().Transform(
func(t chroma.StyleEntry) chroma.StyleEntry { func(t chroma.StyleEntry) chroma.StyleEntry {
if _, ok := bg.(lipgloss.NoColor); ok {
return t
}
r, g, b, _ := bg.RGBA() r, g, b, _ := bg.RGBA()
t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8)) t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
return t return t
@@ -546,10 +509,18 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
} }
// getColor returns the appropriate hex color string based on terminal background // getColor returns the appropriate hex color string based on terminal background
func getColor(adaptiveColor compat.AdaptiveColor) string { func getColor(adaptiveColor compat.AdaptiveColor) *string {
return stylesi.AdaptiveColorToString(adaptiveColor) return stylesi.AdaptiveColorToString(adaptiveColor)
} }
func getChromaColor(adaptiveColor compat.AdaptiveColor) string {
color := stylesi.AdaptiveColorToString(adaptiveColor)
if color == nil {
return ""
}
return *color
}
// highlightLine applies syntax highlighting to a single line // highlightLine applies syntax highlighting to a single line
func highlightLine(fileName string, line string, bg color.Color) string { func highlightLine(fileName string, line string, bg color.Color) string {
var buf bytes.Buffer var buf bytes.Buffer
@@ -561,11 +532,11 @@ func highlightLine(fileName string, line string, bg color.Color) string {
} }
// createStyles generates the lipgloss styles needed for rendering diffs // createStyles generates the lipgloss styles needed for rendering diffs
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) { func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle stylesi.Style) {
removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg()) removedLineStyle = stylesi.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg()) addedLineStyle = stylesi.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg()) contextLineStyle = stylesi.NewStyle().Background(t.DiffContextBg())
lineNumberStyle = lipgloss.NewStyle().Background(t.DiffLineNumber()).Foreground(t.TextMuted()) lineNumberStyle = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffLineNumber())
return return
} }
@@ -613,9 +584,17 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
currentPos := 0 currentPos := 0
// Get the appropriate color based on terminal background // Get the appropriate color based on terminal background
bgColor := lipgloss.Color(getColor(highlightBg)) bg := getColor(highlightBg)
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundPanel())) fg := getColor(theme.CurrentTheme().BackgroundPanel())
var bgColor color.Color
var fgColor color.Color
if bg != nil {
bgColor = lipgloss.Color(*bg)
}
if fg != nil {
fgColor = lipgloss.Color(*fg)
}
for i := 0; i < len(content); { for i := 0; i < len(content); {
// Check if we're at an ANSI sequence // Check if we're at an ANSI sequence
isAnsi := false isAnsi := false
@@ -651,12 +630,20 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
currentStyle := ansiSequences[currentPos] currentStyle := ansiSequences[currentPos]
// Apply foreground and background highlight // Apply foreground and background highlight
sb.WriteString("\x1b[38;2;") if fgColor != nil {
r, g, b, _ := fgColor.RGBA() sb.WriteString("\x1b[38;2;")
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) r, g, b, _ := fgColor.RGBA()
sb.WriteString("\x1b[48;2;") sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
r, g, b, _ = bgColor.RGBA() } else {
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) sb.WriteString("\x1b[49m")
}
if bgColor != nil {
sb.WriteString("\x1b[48;2;")
r, g, b, _ := bgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
} else {
sb.WriteString("\x1b[39m")
}
sb.WriteString(char) sb.WriteString(char)
// Full reset of all attributes to ensure clean state // Full reset of all attributes to ensure clean state
@@ -677,16 +664,16 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
} }
// renderLinePrefix renders the line number and marker prefix for a diff line // renderLinePrefix renders the line number and marker prefix for a diff line
func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle lipgloss.Style, t theme.Theme) string { func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle stylesi.Style, t theme.Theme) string {
// Style the marker based on line type // Style the marker based on line type
var styledMarker string var styledMarker string
switch dl.Kind { switch dl.Kind {
case LineRemoved: case LineRemoved:
styledMarker = lipgloss.NewStyle().Background(t.DiffRemovedBg()).Foreground(t.DiffRemoved()).Render(marker) styledMarker = stylesi.NewStyle().Foreground(t.DiffRemoved()).Background(t.DiffRemovedBg()).Render(marker)
case LineAdded: case LineAdded:
styledMarker = lipgloss.NewStyle().Background(t.DiffAddedBg()).Foreground(t.DiffAdded()).Render(marker) styledMarker = stylesi.NewStyle().Foreground(t.DiffAdded()).Background(t.DiffAddedBg()).Render(marker)
case LineContext: case LineContext:
styledMarker = lipgloss.NewStyle().Background(t.DiffContextBg()).Foreground(t.TextMuted()).Render(marker) styledMarker = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffContextBg()).Render(marker)
default: default:
styledMarker = marker styledMarker = marker
} }
@@ -695,7 +682,7 @@ func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyl
} }
// renderLineContent renders the content of a diff line with syntax and intra-line highlighting // renderLineContent renders the content of a diff line with syntax and intra-line highlighting
func renderLineContent(fileName string, dl DiffLine, bgStyle lipgloss.Style, highlightColor compat.AdaptiveColor, width int, t theme.Theme) string { func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, highlightColor compat.AdaptiveColor, width int) string {
// Apply syntax highlighting // Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground()) content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
@@ -714,7 +701,7 @@ func renderLineContent(fileName string, dl DiffLine, bgStyle lipgloss.Style, hig
ansi.Truncate( ansi.Truncate(
content, content,
width, width,
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."), "...",
), ),
) )
} }
@@ -725,7 +712,7 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
// Determine line style and marker based on line type // Determine line style and marker based on line type
var marker string var marker string
var bgStyle lipgloss.Style var bgStyle stylesi.Style
var lineNum string var lineNum string
var highlightColor compat.AdaptiveColor var highlightColor compat.AdaptiveColor
@@ -733,8 +720,8 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
case LineRemoved: case LineRemoved:
marker = "-" marker = "-"
bgStyle = removedLineStyle bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg()) lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
highlightColor = t.DiffHighlightRemoved() highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
if dl.OldLineNo > 0 { if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d ", dl.OldLineNo) lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
} else { } else {
@@ -743,8 +730,8 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
case LineAdded: case LineAdded:
marker = "+" marker = "+"
bgStyle = addedLineStyle bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg()) lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
highlightColor = t.DiffHighlightAdded() highlightColor = t.DiffHighlightAdded() // TODO: handle "none"
if dl.NewLineNo > 0 { if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf(" %7d", dl.NewLineNo) lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
} else { } else {
@@ -766,7 +753,7 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
// Render the content // Render the content
prefixWidth := ansi.StringWidth(prefix) prefixWidth := ansi.StringWidth(prefix)
contentWidth := width - prefixWidth contentWidth := width - prefixWidth
content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth, t) content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth)
return prefix + content return prefix + content
} }
@@ -780,7 +767,7 @@ func renderDiffColumnLine(
t theme.Theme, t theme.Theme,
) string { ) string {
if dl == nil { if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg()) contextLineStyle := stylesi.NewStyle().Background(t.DiffContextBg())
return contextLineStyle.Width(colWidth).Render("") return contextLineStyle.Width(colWidth).Render("")
} }
@@ -788,7 +775,7 @@ func renderDiffColumnLine(
// Determine line style based on line type and column // Determine line style based on line type and column
var marker string var marker string
var bgStyle lipgloss.Style var bgStyle stylesi.Style
var lineNum string var lineNum string
var highlightColor compat.AdaptiveColor var highlightColor compat.AdaptiveColor
@@ -798,8 +785,8 @@ func renderDiffColumnLine(
case LineRemoved: case LineRemoved:
marker = "-" marker = "-"
bgStyle = removedLineStyle bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg()) lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
highlightColor = t.DiffHighlightRemoved() highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
case LineAdded: case LineAdded:
marker = "?" marker = "?"
bgStyle = contextLineStyle bgStyle = contextLineStyle
@@ -818,7 +805,7 @@ func renderDiffColumnLine(
case LineAdded: case LineAdded:
marker = "+" marker = "+"
bgStyle = addedLineStyle bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg()) lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
highlightColor = t.DiffHighlightAdded() highlightColor = t.DiffHighlightAdded()
case LineRemoved: case LineRemoved:
marker = "?" marker = "?"
@@ -849,7 +836,7 @@ func renderDiffColumnLine(
// Render the content // Render the content
prefixWidth := ansi.StringWidth(prefix) prefixWidth := ansi.StringWidth(prefix)
contentWidth := colWidth - prefixWidth contentWidth := colWidth - prefixWidth
content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth, t) content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth)
return prefix + content return prefix + content
} }
@@ -881,16 +868,17 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
HighlightIntralineChanges(&hunkCopy) HighlightIntralineChanges(&hunkCopy)
var sb strings.Builder var sb strings.Builder
for _, line := range hunkCopy.Lines { sb.Grow(len(hunkCopy.Lines) * config.Width)
sb.WriteString(renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()))
sb.WriteString("\n") util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
} return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
})
return sb.String() return sb.String()
} }
// RenderSideBySideHunk formats a hunk for side-by-side display // RenderSideBySideHunk formats a hunk for side-by-side display
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string { func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
// Apply options to create the configuration // Apply options to create the configuration
config := NewSideBySideConfig(opts...) config := NewSideBySideConfig(opts...)
@@ -905,16 +893,27 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
pairs := pairLines(hunkCopy.Lines) pairs := pairLines(hunkCopy.Lines)
// Calculate column width // Calculate column width
colWidth := config.TotalWidth / 2 colWidth := config.Width / 2
leftWidth := colWidth leftWidth := colWidth
rightWidth := config.TotalWidth - colWidth rightWidth := config.Width - colWidth
var sb strings.Builder var sb strings.Builder
for _, p := range pairs {
leftStr := renderLeftColumn(fileName, p.left, leftWidth) util.WriteStringsPar(&sb, pairs, func(p linePair) string {
rightStr := renderRightColumn(fileName, p.right, rightWidth) wg := &sync.WaitGroup{}
sb.WriteString(leftStr + rightStr + "\n") var leftStr, rightStr string
} wg.Add(2)
go func() {
defer wg.Done()
leftStr = renderLeftColumn(fileName, p.left, leftWidth)
}()
go func() {
defer wg.Done()
rightStr = renderRightColumn(fileName, p.right, rightWidth)
}()
wg.Wait()
return leftStr + rightStr + "\n"
})
return sb.String() return sb.String()
} }
@@ -927,33 +926,24 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption)
} }
var sb strings.Builder var sb strings.Builder
for _, h := range diffResult.Hunks { util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
sb.WriteString(RenderUnifiedHunk(filename, h, opts...)) return RenderUnifiedHunk(filename, h, opts...)
} })
return sb.String(), nil return sb.String(), nil
} }
// FormatDiff creates a side-by-side formatted view of a diff // FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) { func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
// t := theme.CurrentTheme()
diffResult, err := ParseUnifiedDiff(diffText) diffResult, err := ParseUnifiedDiff(diffText)
if err != nil { if err != nil {
return "", err return "", err
} }
var sb strings.Builder var sb strings.Builder
// config := NewSideBySideConfig(opts...) util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
for _, h := range diffResult.Hunks { return RenderSideBySideHunk(filename, h, opts...)
// sb.WriteString( })
// lipgloss.NewStyle().
// Background(t.DiffHunkHeader()).
// Foreground(t.Background()).
// Width(config.TotalWidth).
// Render(h.Header) + "\n",
// )
sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
}
return sb.String(), nil return sb.String(), nil
} }
@@ -0,0 +1,281 @@
package fileviewer
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type DiffStyle int
const (
DiffStyleSplit DiffStyle = iota
DiffStyleUnified
)
type Model struct {
app *app.App
width, height int
viewport viewport.Model
filename *string
content *string
isDiff *bool
diffStyle DiffStyle
}
type fileRenderedMsg struct {
content string
}
func New(app *app.App) Model {
vp := viewport.New()
m := Model{
app: app,
viewport: vp,
diffStyle: DiffStyleUnified,
}
if app.State.SplitDiff {
m.diffStyle = DiffStyleSplit
}
return m
}
func (m Model) Init() tea.Cmd {
return m.viewport.Init()
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case fileRenderedMsg:
m.viewport.SetContent(msg.content)
return m, util.CmdHandler(app.FileRenderedMsg{
FilePath: *m.filename,
})
case dialog.ThemeSelectedMsg:
return m, m.render()
case tea.KeyMsg:
switch msg.String() {
// TODO
}
}
vp, cmd := m.viewport.Update(msg)
m.viewport = vp
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
if !m.HasFile() {
return ""
}
header := *m.filename
header = styles.NewStyle().
Padding(1, 2).
Width(m.width).
Background(theme.CurrentTheme().BackgroundElement()).
Foreground(theme.CurrentTheme().Text()).
Render(header)
t := theme.CurrentTheme()
close := m.app.Key(commands.FileCloseCommand)
diffToggle := m.app.Key(commands.FileDiffToggleCommand)
if m.isDiff == nil || *m.isDiff == false {
diffToggle = ""
}
layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand)
background := t.Background()
footer := layout.Render(
layout.FlexOptions{
Background: &background,
Direction: layout.Row,
Justify: layout.JustifyCenter,
Align: layout.AlignStretch,
Width: m.width - 2,
Gap: 5,
},
layout.FlexItem{
View: close,
},
layout.FlexItem{
View: layoutToggle,
},
layout.FlexItem{
View: diffToggle,
},
)
footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer)
return header + "\n" + m.viewport.View() + "\n" + footer
}
func (m *Model) Clear() (Model, tea.Cmd) {
m.filename = nil
m.content = nil
m.isDiff = nil
return *m, m.render()
}
func (m *Model) ToggleDiff() (Model, tea.Cmd) {
switch m.diffStyle {
case DiffStyleSplit:
m.diffStyle = DiffStyleUnified
default:
m.diffStyle = DiffStyleSplit
}
return *m, m.render()
}
func (m *Model) DiffStyle() DiffStyle {
return m.diffStyle
}
func (m Model) HasFile() bool {
return m.filename != nil && m.content != nil
}
func (m Model) Filename() string {
if m.filename == nil {
return ""
}
return *m.filename
}
func (m *Model) SetSize(width, height int) (Model, tea.Cmd) {
if m.width != width || m.height != height {
m.width = width
m.height = height
m.viewport.SetWidth(width)
m.viewport.SetHeight(height - 4)
return *m, m.render()
}
return *m, nil
}
func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) {
m.filename = &filename
m.content = &content
m.isDiff = &isDiff
return *m, m.render()
}
func (m *Model) render() tea.Cmd {
if m.filename == nil || m.content == nil {
m.viewport.SetContent("")
return nil
}
return func() tea.Msg {
t := theme.CurrentTheme()
var rendered string
if m.isDiff != nil && *m.isDiff {
diffResult := ""
var err error
if m.diffStyle == DiffStyleSplit {
diffResult, err = diff.FormatDiff(
*m.filename,
*m.content,
diff.WithWidth(m.width),
)
} else if m.diffStyle == DiffStyleUnified {
diffResult, err = diff.FormatUnifiedDiff(
*m.filename,
*m.content,
diff.WithWidth(m.width),
)
}
if err != nil {
rendered = styles.NewStyle().
Foreground(t.Error()).
Render(fmt.Sprintf("Error rendering diff: %v", err))
} else {
rendered = strings.TrimRight(diffResult, "\n")
}
} else {
rendered = util.RenderFile(
*m.filename,
*m.content,
m.width,
)
}
rendered = styles.NewStyle().
Width(m.width).
Background(t.BackgroundPanel()).
Render(rendered)
return fileRenderedMsg{
content: rendered,
}
}
}
func (m *Model) ScrollTo(line int) {
m.viewport.SetYOffset(line)
}
func (m *Model) ScrollToBottom() {
m.viewport.GotoBottom()
}
func (m *Model) ScrollToTop() {
m.viewport.GotoTop()
}
func (m *Model) PageUp() (Model, tea.Cmd) {
m.viewport.ViewUp()
return *m, nil
}
func (m *Model) PageDown() (Model, tea.Cmd) {
m.viewport.ViewDown()
return *m, nil
}
func (m *Model) HalfPageUp() (Model, tea.Cmd) {
m.viewport.HalfViewUp()
return *m, nil
}
func (m *Model) HalfPageDown() (Model, tea.Cmd) {
m.viewport.HalfViewDown()
return *m, nil
}
func (m Model) AtTop() bool {
return m.viewport.AtTop()
}
func (m Model) AtBottom() bool {
return m.viewport.AtBottom()
}
func (m Model) ScrollPercent() float64 {
return m.viewport.ScrollPercent()
}
func (m Model) TotalLineCount() int {
return m.viewport.TotalLineCount()
}
func (m Model) VisibleLineCount() int {
return m.viewport.VisibleLineCount()
}
@@ -5,7 +5,6 @@ import (
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/muesli/reflow/truncate" "github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
@@ -174,19 +173,20 @@ type StringItem string
func (s StringItem) Render(selected bool, width int) string { func (s StringItem) Render(selected bool, width int) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() baseStyle := styles.NewStyle()
truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...") truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
var itemStyle lipgloss.Style var itemStyle styles.Style
if selected { if selected {
itemStyle = baseStyle. itemStyle = baseStyle.
Background(t.Primary()). Background(t.Primary()).
Foreground(t.Background()). Foreground(t.BackgroundElement()).
Width(width). Width(width).
PaddingLeft(1) PaddingLeft(1)
} else { } else {
itemStyle = baseStyle. itemStyle = baseStyle.
Foreground(t.TextMuted()).
PaddingLeft(1) PaddingLeft(1)
} }
@@ -90,12 +90,8 @@ func (m *Modal) Render(contentView string, background string) string {
innerWidth := outerWidth - 4 innerWidth := outerWidth - 4
// Base style for the modal baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement())
baseStyle := styles.BaseStyle().
Background(t.BackgroundElement()).
Foreground(t.TextMuted())
// Add title if provided
var finalContent string var finalContent string
if m.title != "" { if m.title != "" {
titleStyle := baseStyle. titleStyle := baseStyle.
@@ -139,11 +135,11 @@ func (m *Modal) Render(contentView string, background string) string {
col := (bgWidth - modalWidth) / 2 col := (bgWidth - modalWidth) / 2
return layout.PlaceOverlay( return layout.PlaceOverlay(
col, col-1, // TODO: whyyyyy
row, row,
modalView, modalView,
background, background,
layout.WithOverlayBorder(), layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(t.Primary()), layout.WithOverlayBorderColor(t.BorderActive()),
) )
} }
+2 -4
View File
@@ -3,7 +3,7 @@ package qr
import ( import (
"strings" "strings"
"github.com/charmbracelet/lipgloss/v2" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"rsc.io/qr" "rsc.io/qr"
) )
@@ -23,9 +23,7 @@ func Generate(text string) (string, int, error) {
} }
// Create lipgloss style for QR code with theme colors // Create lipgloss style for QR code with theme colors
qrStyle := lipgloss.NewStyle(). qrStyle := styles.NewStyleWithColors(t.Text(), t.Background())
Foreground(t.Text()).
Background(t.Background())
var result strings.Builder var result strings.Builder
@@ -36,18 +36,19 @@ func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m statusComponent) logo() string { func (m statusComponent) logo() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
base := lipgloss.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
emphasis := lipgloss.NewStyle().Bold(true).Background(t.BackgroundElement()).Foreground(t.Text()).Render emphasis := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundElement()).Bold(true).Render
open := base("open") open := base("open")
code := emphasis("code ") code := emphasis("code ")
version := base(m.app.Version) version := base(m.app.Version)
return styles.Padded(). return styles.NewStyle().
Background(t.BackgroundElement()). Background(t.BackgroundElement()).
Padding(0, 1).
Render(open + code + version) Render(open + code + version)
} }
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string { func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M) // Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string var formattedTokens string
switch { switch {
@@ -76,8 +77,8 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
func (m statusComponent) View() string { func (m statusComponent) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
if m.app.Session.Id == "" { if m.app.Session.ID == "" {
return styles.BaseStyle(). return styles.NewStyle().
Background(t.Background()). Background(t.Background()).
Width(m.width). Width(m.width).
Height(2). Height(2).
@@ -86,34 +87,38 @@ func (m statusComponent) View() string {
logo := m.logo() logo := m.logo()
cwd := styles.Padded(). cwd := styles.NewStyle().
Foreground(t.TextMuted()). Foreground(t.TextMuted()).
Background(t.BackgroundPanel()). Background(t.BackgroundPanel()).
Padding(0, 1).
Render(m.app.Info.Path.Cwd) Render(m.app.Info.Path.Cwd)
sessionInfo := "" sessionInfo := ""
if m.app.Session.Id != "" { if m.app.Session.ID != "" {
tokens := float32(0) tokens := float64(0)
cost := float32(0) cost := float64(0)
contextWindow := m.app.Model.Limit.Context contextWindow := m.app.Model.Limit.Context
for _, message := range m.app.Messages { for _, message := range m.app.Messages {
if message.Metadata.Assistant != nil { cost += message.Metadata.Assistant.Cost
cost += message.Metadata.Assistant.Cost usage := message.Metadata.Assistant.Tokens
usage := message.Metadata.Assistant.Tokens if usage.Output > 0 {
if usage.Output > 0 { if message.Metadata.Assistant.Summary {
tokens = (usage.Input + tokens = usage.Output
usage.Cache.Write + continue
usage.Cache.Read +
usage.Output +
usage.Reasoning)
} }
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
} }
} }
sessionInfo = styles.Padded(). sessionInfo = styles.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.TextMuted()). Foreground(t.TextMuted()).
Background(t.BackgroundElement()).
Padding(0, 1).
Render(formatTokensAndCost(tokens, contextWindow, cost)) Render(formatTokensAndCost(tokens, contextWindow, cost))
} }
@@ -123,11 +128,11 @@ func (m statusComponent) View() string {
0, 0,
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo), m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
) )
spacer := lipgloss.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("") spacer := styles.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
status := logo + cwd + spacer + sessionInfo status := logo + cwd + spacer + sessionInfo
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("") blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status return blank + "\n" + status
} }
@@ -90,9 +90,9 @@ func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) {
func (tm *ToastManager) renderSingleToast(toast Toast) string { func (tm *ToastManager) renderSingleToast(toast Toast) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle(). baseStyle := styles.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.Text()). Foreground(t.Text()).
Background(t.BackgroundElement()).
Padding(1, 2) Padding(1, 2)
maxWidth := max(40, layout.Current.Viewport.Width/3) maxWidth := max(40, layout.Current.Viewport.Width/3)
@@ -101,15 +101,14 @@ func (tm *ToastManager) renderSingleToast(toast Toast) string {
// Build content with wrapping // Build content with wrapping
var content strings.Builder var content strings.Builder
if toast.Title != nil { if toast.Title != nil {
titleStyle := lipgloss.NewStyle(). titleStyle := styles.NewStyle().Foreground(toast.Color).
Foreground(toast.Color).
Bold(true) Bold(true)
content.WriteString(titleStyle.Render(*toast.Title)) content.WriteString(titleStyle.Render(*toast.Title))
content.WriteString("\n") content.WriteString("\n")
} }
// Wrap message text // Wrap message text
messageStyle := lipgloss.NewStyle() messageStyle := styles.NewStyle()
contentWidth := lipgloss.Width(toast.Message) contentWidth := lipgloss.Width(toast.Message)
if contentWidth > contentMaxWidth { if contentWidth > contentMaxWidth {
messageStyle = messageStyle.Width(contentMaxWidth) messageStyle = messageStyle.Width(contentMaxWidth)

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