Compare commits

..

196 Commits

Author SHA1 Message Date
opencode 785d0b60b6 release: v1.0.127 2025-12-02 16:05:59 +00:00
Adam 67ab9dc4d0 fix: share page ssr 2025-12-02 07:08:25 -06:00
Github Action 77494cb7df Update Nix flake.lock and hashes 2025-12-02 12:52:28 +00:00
GitHub Action 08f8856d6c chore: format code 2025-12-02 12:51:37 +00:00
Adam 79a4c1544d fix: type error 2025-12-02 06:51:05 -06:00
Adam c0a35141e6 feat: better code and diff rendering performance 2025-12-02 06:50:21 -06:00
GitHub Action 221bb64aeb ignore: update download stats 2025-12-02 2025-12-02 12:04:35 +00:00
David Hill 2555dba188 fix: update the install copy 2025-12-02 11:37:41 +00:00
Aiden Cline 6355ed6ae7 feat: add overridable review slash command (#4973) 2025-12-02 00:18:58 -06:00
Ariane Emory 1864e8c863 feat: toggle tool details visibility (resolves #4824) (#4882)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-12-01 19:38:44 -06:00
Adam 61d0d66dae fix: install orange 2025-12-01 19:21:55 -06:00
Aiden Cline 86522f1b3e fix: tui crash when no authed providers and default provider disabled (#4964) 2025-12-01 18:35:40 -06:00
Frank dc32705bc9 zen: remove unnecessary transactions 2025-12-01 18:33:32 -05:00
Stephen Collings 1eaf5c31d3 fix(auth): Respect disabled/enabled providers config in auth login (#4940)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-01 17:01:10 -06:00
Aiden Cline 677b19e22e fix: add .quiet 2025-12-01 16:59:32 -06:00
opencode-agent[bot] 8e248ae045 fix: respect npm registry (#4958)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-01 16:58:39 -06:00
Adam 0acefd5c08 fix: safari icons 2025-12-01 16:27:12 -06:00
Aiden Cline 0331931f56 fix: sanitize more invalid schema cases for gemini models 2025-12-01 16:19:10 -06:00
Dax Raad 01e2c9cc21 core: fix share compaction reprocessing same events by making storage list boundaries exclusive 2025-12-01 16:58:59 -05:00
Dax Raad 4acb645f04 core: add debug logging to troubleshoot share compaction loading 2025-12-01 16:46:57 -05:00
Github Action ef664039ac Update Nix flake.lock and hashes 2025-12-01 21:38:38 +00:00
Dax Raad 993422c6af core: prevent share tests from cleaning up storage used by other tests 2025-12-01 16:37:54 -05:00
Dax Raad c950d59047 fix types 2025-12-01 16:36:56 -05:00
GitHub Action cc726e0200 chore: format code 2025-12-01 21:35:51 +00:00
Dax Raad 3d99dc78db core: reduce latency when loading shared sessions through event compaction 2025-12-01 16:35:07 -05:00
Dax Raad 95c3a8b805 limit grep line length to 2000 2025-12-01 16:35:07 -05:00
Aiden Cline f1bb5870ce fix: copilot responses bug w/ gpt 5 mini 2025-12-01 14:36:49 -06:00
GitHub Action 540407e193 chore: format code 2025-12-01 19:03:13 +00:00
Aiden Cline 027d43b5ea fix case where opencode wasn't retrying 2025-12-01 13:02:33 -06:00
opencode 729a6eda23 release: v1.0.126 2025-12-01 19:00:00 +00:00
blacksmith-sh[bot] cbbd21e2e5 .github/workflows: Migrate workflows to Blacksmith runners (#4941)
Co-authored-by: blacksmith-sh[bot] <157653362+blacksmith-sh[bot]@users.noreply.github.com>
2025-12-01 09:43:33 -05:00
GitHub Action e7d45ca617 chore: format code 2025-12-01 14:43:23 +00:00
Dax Raad 0ddfdb55d3 log fetch time 2025-12-01 09:35:08 -05:00
GitHub Action fc439455a7 chore: format code 2025-12-01 14:23:36 +00:00
opencode c0fc02769b release: v1.0.125 2025-12-01 14:23:35 +00:00
Dax Raad 068ac68496 docs: add rebase instructions to commit command 2025-12-01 09:14:54 -05:00
Dax Raad 7da6a22df2 core: close SSE stream when instance is disposed 2025-12-01 09:14:00 -05:00
Sebastian Herrlinger e37aeb6e6a no conceal for write tool output 2025-12-01 15:12:47 +01:00
GitHub Action eeb0d2b8e0 chore: format code 2025-12-01 13:27:29 +00:00
adamelmore bf8c866bf7 chore: otf fonts 2025-12-01 07:20:53 -06:00
GitHub Action 75354b0002 ignore: update download stats 2025-12-01 2025-12-01 12:08:56 +00:00
opencode 4eb4d97d51 release: v1.0.124 2025-12-01 09:20:29 +00:00
Aiden Cline b1b82977ec tweak: better err msgs 2025-12-01 01:33:32 -06:00
Aiden Cline f6262460ff fix: drop absolute flag to resolve issues when using older git versions 2025-12-01 00:23:19 -06:00
GitHub Action 560a610384 chore: format code 2025-12-01 02:53:41 +00:00
Aiden Cline 0308b2ff98 bump anthropic plugin to fix header issue 2025-11-30 20:52:50 -06:00
opencode 5b92d49be7 release: v1.0.123 2025-12-01 01:07:50 +00:00
Github Action 0386d0ae09 Update Nix flake.lock and hashes 2025-12-01 01:00:23 +00:00
Sebastian Herrlinger 28bec57e1d bump opentui to v0.1.54, removing the cc flicker feature, fixing diffs and input event handling 2025-12-01 01:57:55 +01:00
Shantur Rathore aaa31f02af tweak: compaction prompt (#4838)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-30 14:31:57 -06:00
Justin Vogt ff609a52c1 docs: explore subagent (#4893) 2025-11-30 14:04:27 -06:00
GitHub Action 1e30793f0a ignore: update download stats 2025-11-30 2025-11-30 12:04:23 +00:00
Adam 5268eb479d feat(share): split diffs on wide screens 2025-11-30 05:29:07 -06:00
Aiden Cline a4eba2e6e9 tweak: plan prompt 2025-11-30 01:03:35 -06:00
Aiden Cline 0f30115205 chore: cleanup 2025-11-30 00:32:43 -06:00
opencode-agent[bot] ae500ea01d Added "Open docs" command to palette (#4915)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-29 23:49:45 -06:00
Justin Vogt 087479d459 docs: Move tip to correct tool (#4891) 2025-11-29 22:03:20 -06:00
Aiden Cline 6e2379a28c get codex working in copilot (#4914)
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2025-11-29 21:47:29 -06:00
opencode 262fa184fd release: v1.0.122 2025-11-30 00:35:34 +00:00
Github Action 9a7e1c154d Update Nix flake.lock and hashes 2025-11-29 23:56:14 +00:00
Sebastian Herrlinger 5bf9193dfa bump opentui to v0.1.53, fixing an event listener leak in the line number renderable (perf) 2025-11-30 00:52:32 +01:00
Sebastian Herrlinger 180fb3f39d tweak spinner to be bg independent 2025-11-30 00:50:30 +01:00
GitHub Action 7e6b7314f4 chore: format code 2025-11-29 21:41:00 +00:00
Dax Raad a262508fb8 core: clarify general agent can execute multiple parallel tasks 2025-11-29 16:40:15 -05:00
Dax Raad 80ff24b65a ci: allow snapshot builds from any branch starting with snapshot- prefix 2025-11-29 21:30:38 +00:00
opencode 012aa67e42 release: v1.0.121 2025-11-29 21:30:38 +00:00
Github Action 0a1f12a583 Update Nix flake.lock and hashes 2025-11-29 21:20:24 +00:00
Sebastian Herrlinger f17dc812d0 upgrade opentui to v0.1.52, fixing #4906 - key repeat handling 2025-11-29 22:18:08 +01:00
kavin 1854d85ccc fix(tui): add missing fg color to permission keybind hints (#4899)
Co-authored-by: Github Action <action@github.com>
2025-11-29 14:22:20 -06:00
Adam 2c4d1fb8b4 chore: cleanup duplicate markup 2025-11-29 06:18:51 -06:00
GitHub Action d8fa7cf65d ignore: update download stats 2025-11-29 2025-11-29 12:04:12 +00:00
Dorian Karter 7d8d360138 fix: minor ui bug for transparent backgrounds (#4886) 2025-11-28 23:58:44 -06:00
Dax Raad d80880350d core: improve explore agent description to clarify tool availability 2025-11-28 21:47:45 -05:00
GitHub Action b693ed0dbd chore: format code 2025-11-29 02:44:40 +00:00
Dax Raad 83f961a7c2 Merge remote-tracking branch 'origin/dev' into dev 2025-11-28 21:44:02 -05:00
Dax Raad a093917db1 core: update generated types to include explore agent configuration 2025-11-28 21:43:56 -05:00
opencode 52716db649 release: v1.0.120 2025-11-29 02:34:23 +00:00
Dax Raad 9ca4b464ea tui: improve task display in session view to show tool names and completion status 2025-11-28 21:19:15 -05:00
Dax Raad 204a31b6bb Merge remote-tracking branch 'origin/dev' into dev 2025-11-28 21:13:13 -05:00
Dax Raad 813d287a09 core: add explore agent for fast codebase navigation and improve task UI display 2025-11-28 21:13:07 -05:00
Adam 4dd9f33eba fix: diffs double rendering when CSR'd 2025-11-28 20:08:50 -06:00
Adam 5953378a12 fix: theme-color value 2025-11-28 19:57:01 -06:00
Github Action b419eed295 Update Nix flake.lock and hashes 2025-11-29 01:55:02 +00:00
Sebastian Herrlinger 52deb7f352 opentui diffs 2025-11-29 02:52:17 +01:00
Dax Raad a4f3aecbaa ignore 2025-11-28 20:29:58 -05:00
GitHub Action 49ff6a852a chore: format code 2025-11-28 22:43:33 +00:00
Justin Vogt 7f537d2e98 docs: Add tip to tools about subagents re todos (#4875) 2025-11-28 15:49:24 -06:00
Aiden Cline 753443b16f ci: add community contributors to the changelog 2025-11-28 15:44:21 -06:00
Ariane Emory 33c63be980 feat: persist thinking blocks display to KV and indicate its current display state in the command_list (resolves #4582) (#4810)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-28 15:01:05 -06:00
Aiden Cline b6efca42b4 ci: use haiku 2025-11-28 14:41:32 -06:00
Github Action fa6eadc39a Update Nix flake.lock and hashes 2025-11-28 20:32:46 +00:00
Aiden Cline 8789acefa6 bump openrouter & google ai sdk packages 2025-11-28 14:30:22 -06:00
Aiden Cline 0e280017e6 Revert "fix: title gen when first msg(s) are shell invocations (#4874)"
This reverts commit 17e8322c29.
2025-11-28 12:18:02 -06:00
Aiden Cline 17e8322c29 fix: title gen when first msg(s) are shell invocations (#4874)
Co-authored-by: GitHub Action <action@github.com>
2025-11-28 11:55:22 -06:00
Sergio Garcia 96eda740cd docs: formatter: false and lsp: false (#4833)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2025-11-28 11:22:48 -06:00
jaov fa84612357 tweak: gemini retry message to not be explicitly about gemini 3 (#4864)
Co-authored-by: jesuso <j.ochoa@norteconecta.net>
2025-11-28 11:14:53 -06:00
Dax Raad cf1f63eda3 ci stuff 2025-11-28 10:59:52 -05:00
Dax Raad 9704f5ce89 add otui-diffs 2025-11-28 10:59:07 -05:00
GitHub Action 0eaec2af82 ignore: update download stats 2025-11-28 2025-11-28 12:04:42 +00:00
Adam 398d35dc97 fix: theme-color value 2025-11-28 05:48:07 -06:00
Adam 5efeaae093 fix: desktop and share layouts 2025-11-28 05:35:35 -06:00
Adam cb2dd34a5e fix: unified diff as default 2025-11-28 05:35:35 -06:00
Christoph 7112a706b8 core: add built-in Dart LSP server and formatter (#4841) 2025-11-28 00:33:45 -06:00
Github Action 025a47d01f Update Nix flake.lock and hashes 2025-11-28 06:27:49 +00:00
DS 13f89fdb8f fix: filter empty messages in toModelMessage (#4811) 2025-11-28 00:26:48 -06:00
Aiden Cline cc78d50ef6 bump anthropic package 2025-11-28 00:25:16 -06:00
Adam a8985b1849 fix(desktop): layout 2025-11-27 20:15:49 -06:00
Adam 6a1552f65c fix: unwrap solid store part 2025-11-27 20:15:49 -06:00
Dax Raad 776091cc23 ci: add bun version check to pre-push hook to ensure version consistency 2025-11-27 15:50:23 -05:00
Dax Raad f385524f48 fix lock 2025-11-27 15:48:16 -05:00
Dax Raad 350982e636 tui: simplify model dialog ordering logic to reduce complexity 2025-11-27 14:38:51 -05:00
Dax Raad 5854455815 tui: improve provider dialog text clarity for better user guidance 2025-11-27 14:09:53 -05:00
Dax Raad 9ecaf618db tui: fix provider sorting to prioritize recommended options 2025-11-27 13:54:42 -05:00
Dax Raad 95b667d21e tui: remove cancel keybind hint from prompt dialog to simplify UI 2025-11-27 13:48:34 -05:00
Dax Raad a0b689c140 tui: hide favorite keybind in model dialog when disconnected to prevent errors 2025-11-27 13:42:36 -05:00
GitHub Action ea52ed41be chore: format code 2025-11-27 17:15:41 +00:00
Jay V 5a50d54fda ignore: lock 2025-11-27 12:14:50 -05:00
Jay V 35d118b0c4 ignore: add reply-to support for enterprise form emails 2025-11-27 12:12:44 -05:00
Albert O'Shea ea7c213f5d nix: fix workflow failing on PRs (#4820)
Co-authored-by: Github Action <action@github.com>
2025-11-27 10:05:51 -06:00
Frank 70dd6dd394 doc: slashing kimi k2 thinking price 2025-11-27 09:58:57 -05:00
GitHub Action 049510afbd ignore: update download stats 2025-11-27 2025-11-27 12:04:30 +00:00
Adam c120447fd0 fix: desktop layout and scroll gutters 2025-11-27 05:41:50 -06:00
Adam feb1f36126 fix: session turn margins 2025-11-27 05:25:39 -06:00
GitHub Action d6ef47bb2d chore: format code 2025-11-27 11:06:46 +00:00
Adam 50fd416d49 fix: simpler sanitize 2025-11-27 05:05:55 -06:00
opencode aef6904247 release: v1.0.119 2025-11-27 03:15:40 +00:00
Dax Raad 0bf40faf95 core: prevent codesearch and websearch tools when webfetch permission is denied 2025-11-26 22:08:50 -05:00
GitHub Action c90987c4b0 chore: format code 2025-11-27 03:08:33 +00:00
Dax Raad 0e08655407 core: prevent external diff tools from interfering with snapshot generation 2025-11-26 22:07:51 -05:00
opencode 427887db9c release: v1.0.118 2025-11-27 02:43:07 +00:00
Dax Raad a718622498 tui: prevent footer from shrinking when terminal is resized 2025-11-26 21:35:56 -05:00
Github Action 4e83107d79 Update Nix flake.lock and hashes 2025-11-27 01:33:35 +00:00
GitHub Action 04b6e72820 chore: format code 2025-11-27 01:32:04 +00:00
Dax Raad 501a2539c7 revert ts lsp to monorepo root 2025-11-26 20:31:26 -05:00
opencode 6a9856d480 release: v1.0.117 2025-11-27 01:29:00 +00:00
Dax Raad 2c8d42d997 roll back vtsls 2025-11-26 20:18:40 -05:00
Dax Raad 9c237f0bfb temporarily restrict codesearch and websearch to opencode zen users. need to figure out how to opt out for enterprise users who do not want this 2025-11-26 20:13:20 -05:00
Dax 63bfe76720 tui design refinement (#4809) 2025-11-26 20:11:39 -05:00
Aiden Cline 99d7ff47c4 enable parcel file watcher, expand parcel ignore patterns, replace fs watcher for git branches with parcel (#4805) 2025-11-26 17:33:43 -06:00
Aiden Cline 3ff0eb3065 Revert "fix: disable virtual extmarks for file/agent mentions (#4731)"
This reverts commit 673dbeee09.
2025-11-26 14:55:39 -06:00
GitHub Action 4d2b265dc4 chore: format code 2025-11-26 19:10:51 +00:00
rosmur 1854245bd3 docs: add llama.cpp provider for local LLM inference (#4769)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-26 13:10:15 -06:00
Aiden Cline 4d07034930 fix: svg paste bug 2025-11-26 12:38:48 -06:00
Dax Raad 98031173b6 lil branch refactor 2025-11-26 12:34:48 -05:00
GitHub Action e8e474597c chore: format code 2025-11-26 17:26:46 +00:00
Tommy D. Rossi 382758790c fix(tui): dedupe file references in prompt (#4775) 2025-11-26 11:26:05 -06:00
opencode c33920f59d release: v1.0.115 2025-11-26 16:57:35 +00:00
Jensen 33f004d4b6 fix(tui): show bootstrap errors instead of {} to trace (#4779)
Co-authored-by: Github Action <action@github.com>
2025-11-26 10:49:55 -06:00
Yug Agarwal 8963b536ee docs: IO.NET Provider (#4762) 2025-11-26 10:44:05 -06:00
GitHub Action 51455e2a1e ignore: update download stats 2025-11-26 2025-11-26 12:04:44 +00:00
Adam 30d6a26e3e fix: useData 2025-11-26 06:03:13 -06:00
Adam cd4fabd11b fix: scroll gutter padding 2025-11-26 05:58:30 -06:00
Github Action 9a8b8f26ac Update Nix flake.lock and hashes 2025-11-26 11:31:37 +00:00
Adam 2f73b16b57 deps: update pierre diffs 2025-11-26 05:29:31 -06:00
opencode-agent[bot] df9952c291 Renamed vcs.changed to vcs.branch.updated (#4771)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-26 01:17:59 -06:00
Dmitry Halushka ee946d8128 fix: transform MCP tool schemas for Google/Gemini compatibility (#4538)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Github Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-26 00:58:20 -06:00
Aiden Cline ec8f2e078e Remove keybinds from favorites feature, keep functionality 2025-11-26 00:45:33 -06:00
shuv 335f46122b Add favorites to model selector (#23) (#4343)
Co-authored-by: Github Action <action@github.com>
2025-11-26 00:41:41 -06:00
Meysam Najafi Fard 73eae191e9 fix: handle remote image URLs in paste handler (#4691)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-26 00:34:22 -06:00
Aiden Cline 14e823e938 ignore: fix type issue 2025-11-26 00:14:04 -06:00
Aiden Cline 2fbd462e6e Reapply "feat(github): add ability to react to PR Review Comments in Workflow (#4705)"
This reverts commit e1cc98d448.
2025-11-25 23:51:46 -06:00
Aiden Cline e1cc98d448 Revert "feat(github): add ability to react to PR Review Comments in Workflow (#4705)"
This reverts commit 0ce64962d4.
2025-11-25 23:51:23 -06:00
U Cirello 0ce64962d4 feat(github): add ability to react to PR Review Comments in Workflow (#4705)
Co-authored-by: GitHub Action <action@github.com>
2025-11-25 23:45:19 -06:00
george larson 338229193f docs: add Venice.ai provider (#4748)
Co-authored-by: George Larson <georgeglarson@users.noreply.github.com>
2025-11-25 23:40:52 -06:00
Ariane Emory 57644a4be8 feat: add a diff_style option to allow disabling columnar diffs (resolve #4677) (#4756)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-25 23:34:58 -06:00
Frank da2099137a zen: trial 2025-11-25 23:44:57 -05:00
Aiden Cline 09bc8d9ca4 show current git branch in tui (#4765)
Co-authored-by: GitHub Action <action@github.com>
2025-11-25 21:39:20 -06:00
Dax Raad d95f724303 enable exa code/websearch 2025-11-25 22:06:26 -05:00
Frank c413c3ed8f wip: zen 2025-11-25 22:01:34 -05:00
Adam 5f56be0ad4 fix: pierre separators 2025-11-25 20:39:20 -06:00
Adam ef441d5cff chore: cleanup pierre stuff 2025-11-25 20:39:20 -06:00
GitHub Action 16a188c524 chore: format code 2025-11-26 02:33:36 +00:00
Dax Raad 50c40a8d99 tui: fix event subscription cleanup in SDK context 2025-11-25 21:32:56 -05:00
opencode 4114c8715c release: v1.0.114 2025-11-26 00:32:22 +00:00
GitHub Action ced5fdbe70 chore: format code 2025-11-26 00:24:10 +00:00
Dax Raad b16aa81e0d switch to vtsls for typescript lsp 2025-11-25 19:23:23 -05:00
Aiden Cline b44971668c fix: global prefix handling w/ aws bedrock (#4757) 2025-11-26 00:21:59 +00:00
opencode 0ff4c284e2 release: v1.0.113 2025-11-26 00:21:59 +00:00
Dax Raad e8db95be16 switch typescript lsp to be one per package to ensure it loads when typescript is not installed at root 2025-11-25 19:14:07 -05:00
Shantur Rathore 69c2dd53ad config: add setCacheKey in provider options (#4738)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-25 17:53:56 -06:00
Frank 14a910bd64 wip: zen 2025-11-25 18:04:25 -05:00
GitHub Action 52f97ffdc9 chore: format code 2025-11-25 22:59:55 +00:00
Frank a1e87f6cd9 wip: zen 2025-11-25 17:58:59 -05:00
Frank c2fc41dcd5 wip: zen 2025-11-25 17:57:24 -05:00
Frank b62c7943e7 zen: trial 2025-11-25 17:57:24 -05:00
Tommy D. Rossi 64caeeb12d fix(tui): abort in-progress generation on undo (#4744) 2025-11-25 16:35:34 -06:00
Adam e8ac4a1e99 fix: build error 2025-11-25 16:27:53 -06:00
Adam 19c8654195 fix: missing deps 2025-11-25 16:27:26 -06:00
Haris Gušić 00d7aed797 fix: prompt submit error with opencode -c (#4496)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-25 16:27:06 -06:00
Adam 4477132448 fix: sanitize absolute paths 2025-11-25 16:02:31 -06:00
Aiden Cline eaeea45ace ci: change changelog model 2025-11-25 15:36:26 -06:00
Aiden Cline e404bf33b1 update install script to handle musl & avx 2025-11-25 15:35:10 -06:00
Aiden Cline 79a7edea5e ci: update changelog prompt 2025-11-25 15:34:49 -06:00
Jaga Santagostino 2b05fe2859 docs: improve notes on mobile (#4747) 2025-11-25 15:27:28 -06:00
GitHub Action f8996f0a90 chore: format code 2025-11-25 21:10:31 +00:00
Dax Raad eb04cdac41 better overloaded message 2025-11-25 21:10:31 +00:00
187 changed files with 7938 additions and 1527 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
auto-label:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: write
+1 -1
View File
@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
deploy:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
check-duplicates:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: write
+1 -1
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
jobs:
format:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
steps:
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
notify:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Send nicely-formatted embed to Discord
uses: SethCohen/github-releases-to-discord@v1
+4 -2
View File
@@ -3,6 +3,8 @@ name: opencode
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
opencode:
@@ -11,7 +13,7 @@ jobs:
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
id-token: write
contents: read
@@ -28,4 +30,4 @@ jobs:
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: opencode/glm-4.6
model: opencode/claude-haiku-4-5
+1 -1
View File
@@ -14,7 +14,7 @@ permissions:
jobs:
publish:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
with:
+1 -1
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
publish:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
with:
+1 -1
View File
@@ -25,7 +25,7 @@ permissions:
jobs:
publish:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
with:
+5 -2
View File
@@ -1,17 +1,20 @@
name: snapshot
on:
workflow_dispatch:
push:
branches:
- dev
- fix-snapshot-2
- test-bedrock
- v0
- otui-diffs
- snapshot-*
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
publish:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
with:
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
stats:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs:
zed:
name: Release Zed Extension
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
with:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
typecheck:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
+3 -1
View File
@@ -18,7 +18,8 @@ on:
jobs:
update:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
SYSTEM: x86_64-linux
@@ -29,6 +30,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Setup Nix
uses: DeterminateSystems/nix-installer-action@v20
+7
View File
@@ -1,2 +1,9 @@
#!/bin/sh
# Check if bun version matches package.json
EXPECTED_VERSION=$(grep '"packageManager"' package.json | sed 's/.*"bun@\([^"]*\)".*/\1/')
CURRENT_VERSION=$(bun --version)
if [ "$CURRENT_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Error: Bun version $CURRENT_VERSION does not match expected version $EXPECTED_VERSION from package.json"
exit 1
fi
bun typecheck
+3
View File
@@ -21,3 +21,6 @@ WHAT was done.
do not do generic messages like "improved agent experience" be very specific
about what user facing changes were made
if there are changes do a git pull --rebase
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
+14 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "http://localhost:3000",
// "url": "https://enterprise.dev.opencode.ai",
// },
"provider": {
"opencode": {
@@ -11,4 +11,17 @@
},
},
},
"mcp": {
"exa": {
"type": "remote",
"url": "https://mcp.exa.ai/mcp",
},
"morph": {
"type": "local",
"command": ["bunx", "@morphllm/morphmcp"],
"environment": {
"ENABLED_TOOLS": "warp_grep",
},
},
},
}
+7
View File
@@ -151,3 +151,10 @@
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) |
| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) |
| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) |
| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) |
| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) |
| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) |
+49 -45
View File
@@ -8,6 +8,7 @@
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"typescript": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -19,7 +20,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -47,7 +48,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -74,7 +75,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -98,7 +99,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -122,7 +123,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -163,7 +164,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -191,7 +192,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -207,7 +208,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.112",
"version": "1.0.127",
"bin": {
"opencode": "./bin/opencode",
},
@@ -216,13 +217,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.57",
"@ai-sdk/anthropic": "2.0.45",
"@ai-sdk/anthropic": "2.0.50",
"@ai-sdk/azure": "2.0.73",
"@ai-sdk/google": "2.0.42",
"@ai-sdk/google-vertex": "3.0.74",
"@ai-sdk/google": "2.0.44",
"@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/mcp": "0.0.8",
"@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -234,9 +237,9 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.2.5",
"@opentui/core": "0.1.50",
"@opentui/solid": "0.1.50",
"@openrouter/ai-sdk-provider": "1.2.8",
"@opentui/core": "0.1.54",
"@opentui/solid": "0.1.54",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -255,7 +258,7 @@
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.5",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
"solid-js": "catalog:",
@@ -294,7 +297,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -314,7 +317,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.112",
"version": "1.0.127",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@@ -325,7 +328,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -338,7 +341,7 @@
},
"packages/tauri": {
"name": "@opencode-ai/tauri",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
@@ -351,7 +354,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -383,17 +386,18 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"zod": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:",
},
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -439,7 +443,7 @@
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.5.5",
"@pierre/precision-diffs": "0.6.0-beta.3",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
@@ -490,9 +494,9 @@
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.12", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W+cB1sOWvPcz9qiIsNtD+HxUrBUva2vWv2K1EFukuImX+HA0uZx3EyyOjhYQ9gtf/teqEG80M6OvJ7xx/VLV2A=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.42", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Jdn+3TZm4iIt62CUjjUoIOshqFIXyzNmUDfkSVV4FcjlSo5+AuhzI1KC7QiNHlqPNejzR6NLIqGJx96VAES34g=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.44", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-c5dck36FjqiVoeeMJQLTEmUheoURcGTU/nBT6iJu8/nZiKFT/y8pD85KMDRB7RerRYaaQOtslR2d6/5PditiRw=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.45", "@ai-sdk/google": "2.0.42", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "google-auth-library": "^9.15.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W0375p41RQOheAmy7iJGtuJWQWX/aKkO4sJHf6eIYa3bkz93Cbo1aRG1X7ocyMusLZ3dIaW7x6X9WHD8IHkNfg=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.81", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/google": "2.0.44", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "google-auth-library": "^9.15.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yrl5Ug0Mqwo9ya45oxczgy2RWgpEA/XQQCSFYP+3NZMQ4yA3Iim1vkOjVCsGaZZ8rjVk395abi1ZMZV0/6rqVA=="],
"@ai-sdk/mcp": ["@ai-sdk/mcp@0.0.8", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9y9GuGcZ9/+pMIHfpOCJgZVp+AZMv6TkjX2NVT17SQZvTF2N8LXuCXyoUPyi1PxIxzxl0n463LxxaB2O6olC+Q=="],
@@ -502,7 +506,7 @@
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -1078,27 +1082,27 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.2.5", "", { "dependencies": { "@openrouter/sdk": "^0.1.8" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-NrvJFPvdEUo6DYUQIVWPGfhafuZ2PAIX7+CUMKGknv8TcTNVo0TyP1y5SU7Bgjf/Wup9/74UFKUB07icOhVZjQ=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.2.8", "", { "dependencies": { "@openrouter/sdk": "^0.1.8" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-pQT8AzZBKg9f4bkt4doF486ZlhK0XjKkevrLkiqYgfh1Jplovieu28nK4Y+xy3sF18/mxjqh9/2y6jh01qzLrA=="],
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.50", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.50", "@opentui/core-darwin-x64": "0.1.50", "@opentui/core-linux-arm64": "0.1.50", "@opentui/core-linux-x64": "0.1.50", "@opentui/core-win32-arm64": "0.1.50", "@opentui/core-win32-x64": "0.1.50", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-QhjwT2f8AIQj0gbL/WQ2M93sl2/qp9+Kqxyh4dOhp8z3qnTc5D7J105VrMyeWZW7/P27ubgbFAqqWXrZ4FsuLw=="],
"@opentui/core": ["@opentui/core@0.1.54", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.54", "@opentui/core-darwin-x64": "0.1.54", "@opentui/core-linux-arm64": "0.1.54", "@opentui/core-linux-x64": "0.1.54", "@opentui/core-win32-arm64": "0.1.54", "@opentui/core-win32-x64": "0.1.54", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-NYBVOmAa3JB+bSTxFTUc3Ej3B8Gc364DMsoktVRCYdjQ+AyeMRNdOTj9sMWdDJbXqITK/atrsUXouoE5bL9sWA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.50", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FKqTDOsZl9TXF7KN2SdZKoRHQNvqKSY27AG3jhKCoiyLGdaNCAsaeBWqAmpnL4E4kMkV3aiQSCrKTrYsaevvOg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.54", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LyJ2AI9XA3upckiXLaX/P6tB40pB6euZA5MCikyhwcDVew3Z7NpTI0xHp4gUWHwsSTwmx6u851ofgkf7nMBEJg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.50", "", { "os": "darwin", "cpu": "x64" }, "sha512-GczVNqqpM/HtsgeBB08K6zL1B7oc6Y5G2cMklo06LrYRdDkFdDtY5fNNnJR2/psZWzTrI3M+sLnKWgUGD5CxUQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.54", "", { "os": "darwin", "cpu": "x64" }, "sha512-LZAjXR1OoUr8pewBTHqxupdPS4PC4qQCht0HHGEr1+zN7zu5c+kcp3Uop+NeQTYLjHJftiULukzAfLJMXFuDNA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.50", "", { "os": "linux", "cpu": "arm64" }, "sha512-+CKMhweEXH0tLGM6qqaqk6DyCEmwrTVubTtez/pSM3GgcROSXIBui9TEZpIlPgSCVmjbotGS6eSIg4oU+p9o7w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.54", "", { "os": "linux", "cpu": "arm64" }, "sha512-AC7qBYc4shY28/eR1BvguvfCjLzZJy0mdvadSdDF0XJJIe5vhWJ9UyKsoyLo7pwrd0iFBf5d7fg4kJcbLbZNWg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.50", "", { "os": "linux", "cpu": "x64" }, "sha512-yv5KWiMohAK9bsi1gth9DDZDpoJA1EDHexjhThsPT8EH82g13T088dnJZuJWUE9dr1OwTCQG8DyorNxX3ViEGQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.54", "", { "os": "linux", "cpu": "x64" }, "sha512-S7BAq4CUV6ZfoXDSClJT75XxVSKPOOuRVYvBSHMOgwkBwLHpJZdz0Z4mzcGBBP1nJQ5Gz92TWPYfYMbZvR1lpw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.50", "", { "os": "win32", "cpu": "arm64" }, "sha512-6/6pURTRNTLFKF8IhYVi7U+T/HGMeURav9LIYw7yfcOibd0kLMthmemhS0Lzyk5dmtp0T4V4NmRmtlq/fIzyjQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.54", "", { "os": "win32", "cpu": "arm64" }, "sha512-K+E9i8t6YfN0Ly9moHefRQfR5GSbQUAsqzfrW4TD4bpJBy5y7EKh1mz8ZdES/RwWOSGUCb+JN7/ZQm0OubbdvQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.50", "", { "os": "win32", "cpu": "x64" }, "sha512-EME8GBFq9uCLbH5js8fH7/xY4ZtLIZlt3bkYKT6lPiCNdaf/6ebg+F/ObPXFkJrc8VeV1ql2bXhQ6RLi7izvAA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.54", "", { "os": "win32", "cpu": "x64" }, "sha512-0KsBPRtcqmPM1AoN2Ez9au6uf7y8631W9tXTWnVZRlI2/G3j1AfGReR6vuwrd8O4JfxFXiYWWPfSxYZvWLSRNg=="],
"@opentui/solid": ["@opentui/solid@0.1.50", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.50", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-q778kp/eksh8UOPSQO2h8h9CGGDqepTf9u2WYTS2HYHRAI2SRtUWpN9L7Euyt3BtG9L/wpsIOHK/ufPhQH1X6A=="],
"@opentui/solid": ["@opentui/solid@0.1.54", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.54", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-rr9moI+W3meoD57t4Flyfw33mRSFIX0FOx+t4T3sLNA3Pz5FGU2n4qyxPc21OkAuc23S+YxuEuwbZebnIaLGWA=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1214,7 +1218,7 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.5.5", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-mmDHEWWQ6fmXY5qRNHqodzOxHPwLqVNbbnO/MOpXteOTjd0nVIGy5IcaNwU2WSxhxQRwaUepKyx5+wwPcZLEmw=="],
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.6.0-beta.3", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-1FBm9jhLWZvs7BqN3yG2Wh9SpGuO1us2QsKZlQqSwyCctMr9DRGzYQJ9lF6yR03LHzXs3fuIzO++d9sCObYzrQ=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@@ -2968,7 +2972,7 @@
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
"opentui-spinner": ["opentui-spinner@0.0.5", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-abSWzWA7eyuD0PjerAWbBznLmOQn+8xRDaLGCVIs4ctETi2laNFr5KwicYnPXsHZpPc2neV7WtQm+diCEfOhLA=="],
"opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
@@ -3736,20 +3740,22 @@
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.45", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Ipv62vavDCmrV/oE/lXehL9FzwQuZOnnlhPEftWizx464Wb6lvnBTJx8uhmEYruFSzOWTI95Z33ncZ4tA8E6RQ=="],
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="],
"@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
@@ -4072,7 +4078,7 @@
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.45", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Ipv62vavDCmrV/oE/lXehL9FzwQuZOnnlhPEftWizx464Wb6lvnBTJx8uhmEYruFSzOWTI95Z33ncZ4tA8E6RQ=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="],
@@ -4618,8 +4624,6 @@
"jsonwebtoken/jws/jwa": ["jwa@1.4.2", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw=="],
"opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1763934636,
"narHash": "sha256-9glbI7f1uU+yzQCq5LwLgdZqx6svOhZWkd4JRY265fc=",
"lastModified": 1764611609,
"narHash": "sha256-yU9BNcP0oadUKupw0UKmO9BKDOVIg9NStdJosEbXf8U=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ee09932cedcef15aaf476f9343d1dea2cb77e261",
"rev": "8c29968b3a942f2903f90797f9623737c215737c",
"type": "github"
},
"original": {
+26
View File
@@ -30,6 +30,24 @@ Leave the following comment on a GitHub PR. opencode will implement the requeste
Delete the attachment from S3 when the note is removed /oc
```
#### Review specific code lines
Leave a comment directly on code lines in the PR's "Files" tab. opencode will automatically detect the file, line numbers, and diff context to provide precise responses.
```
[Comment on specific lines in Files tab]
/oc add error handling here
```
When commenting on specific lines, opencode receives:
- The exact file being reviewed
- The specific lines of code
- The surrounding diff context
- Line number information
This allows for more targeted requests without needing to specify file paths or line numbers manually.
## Installation
Run the following command in the terminal from your GitHub repo:
@@ -51,6 +69,8 @@ This will walk you through installing the GitHub app, creating the workflow, and
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
opencode:
@@ -135,3 +155,9 @@ Replace the image URL `https://github.com/user-attachments/assets/xxxxxxxx` with
```
MOCK_EVENT='{"eventName":"issue_comment","repo":{"owner":"sst","repo":"hello-world"},"actor":"fwang","payload":{"issue":{"number":4,"pull_request":{}},"comment":{"id":1,"body":"hey opencode, summarize thread"}}}'
```
### PR review comment event
```
MOCK_EVENT='{"eventName":"pull_request_review_comment","repo":{"owner":"sst","repo":"hello-world"},"actor":"fwang","payload":{"pull_request":{"number":7},"comment":{"id":1,"body":"hey opencode, add error handling","path":"src/components/Button.tsx","diff_hunk":"@@ -45,8 +45,11 @@\n- const handleClick = () => {\n- console.log('clicked')\n+ const handleClick = useCallback(() => {\n+ console.log('clicked')\n+ doSomething()\n+ }, [doSomething])","line":47,"original_line":45,"position":10,"commit_id":"abc123","original_commit_id":"def456"}}}'
```
+44 -7
View File
@@ -5,7 +5,7 @@ import { graphql } from "@octokit/graphql"
import * as core from "@actions/core"
import * as github from "@actions/github"
import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent } from "@octokit/webhooks-types"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
@@ -124,7 +124,7 @@ let exitCode = 0
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
try {
assertContextEvent("issue_comment")
assertContextEvent("issue_comment", "pull_request_review_comment")
assertPayloadKeyword()
await assertOpencodeConnected()
@@ -241,19 +241,43 @@ function createOpencode() {
}
function assertPayloadKeyword() {
const payload = useContext().payload as IssueCommentEvent
const payload = useContext().payload as IssueCommentEvent | PullRequestReviewCommentEvent
const body = payload.comment.body.trim()
if (!body.match(/(?:^|\s)(?:\/opencode|\/oc)(?=$|\s)/)) {
throw new Error("Comments must mention `/opencode` or `/oc`")
}
}
function getReviewCommentContext() {
const context = useContext()
if (context.eventName !== "pull_request_review_comment") {
return null
}
const payload = context.payload as PullRequestReviewCommentEvent
return {
file: payload.comment.path,
diffHunk: payload.comment.diff_hunk,
line: payload.comment.line,
originalLine: payload.comment.original_line,
position: payload.comment.position,
commitId: payload.comment.commit_id,
originalCommitId: payload.comment.original_commit_id,
}
}
async function assertOpencodeConnected() {
let retry = 0
let connected = false
do {
try {
await client.app.get<true>()
await client.app.log<true>({
body: {
service: "github-workflow",
level: "info",
message: "Prepare to react to Github Workflow event",
},
})
connected = true
break
} catch (e) {}
@@ -383,11 +407,24 @@ async function createComment() {
}
async function getUserPrompt() {
const context = useContext()
const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
const reviewContext = getReviewCommentContext()
let prompt = (() => {
const payload = useContext().payload as IssueCommentEvent
const body = payload.comment.body.trim()
if (body === "/opencode" || body === "/oc") return "Summarize this thread"
if (body.includes("/opencode") || body.includes("/oc")) return body
if (body === "/opencode" || body === "/oc") {
if (reviewContext) {
return `Review this code change and suggest improvements for the commented lines:\n\nFile: ${reviewContext.file}\nLines: ${reviewContext.line}\n\n${reviewContext.diffHunk}`
}
return "Summarize this thread"
}
if (body.includes("/opencode") || body.includes("/oc")) {
if (reviewContext) {
return `${body}\n\nContext: You are reviewing a comment on file "${reviewContext.file}" at line ${reviewContext.line}.\n\nDiff context:\n${reviewContext.diffHunk}`
}
return body
}
throw new Error("Comments must mention `/opencode` or `/oc`")
})()
+64 -26
View File
@@ -4,50 +4,89 @@ APP=opencode
MUTED='\033[0;2m'
RED='\033[0;31m'
ORANGE='\033[38;2;255;140;0m'
ORANGE='\033[38;5;214m'
NC='\033[0m' # No Color
requested_version=${VERSION:-}
raw_os=$(uname -s)
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
# Normalize various Unix-like identifiers
case "$raw_os" in
Darwin*) os="darwin" ;;
Linux*) os="linux" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
esac
arch=$(uname -m)
esac
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
elif [[ "$arch" == "x86_64" ]]; then
fi
if [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
if [ "$os" = "linux" ]; then
filename="$APP-$os-$arch.tar.gz"
else
filename="$APP-$os-$arch.zip"
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
if [ "$rosetta_flag" = "1" ]; then
arch="arm64"
fi
fi
case "$filename" in
*"-linux-"*)
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
;;
*"-darwin-"*)
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
;;
*"-windows-"*)
[[ "$arch" == "x64" ]] || exit 1
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
exit 1
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
exit 1
;;
esac
archive_ext=".zip"
if [ "$os" = "linux" ]; then
archive_ext=".tar.gz"
fi
is_musl=false
if [ "$os" = "linux" ]; then
if [ -f /etc/alpine-release ]; then
is_musl=true
fi
if command -v ldd >/dev/null 2>&1; then
if ldd --version 2>&1 | grep -qi musl; then
is_musl=true
fi
fi
fi
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
if [ "$os" = "darwin" ]; then
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
if [ "$avx2" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"
if [ "$needs_baseline" = "true" ]; then
target="$target-baseline"
fi
if [ "$is_musl" = "true" ]; then
target="$target-musl"
fi
filename="$APP-$target$archive_ext"
if [ "$os" = "linux" ]; then
if ! command -v tar >/dev/null 2>&1; then
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
@@ -314,10 +353,9 @@ echo -e "${MUTED}█░░█ █░░█ █▀▀▀ █░░█ ${NC}█░
echo -e "${MUTED}▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ${NC}▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"
echo -e ""
echo -e ""
echo -e "${MUTED}To get started, navigate to a project and run:${NC}"
echo -e "opencode ${MUTED}Use free models${NC}"
echo -e "opencode auth login ${MUTED}Add paid provider API keys${NC}"
echo -e "opencode help ${MUTED}List commands and options${NC}"
echo -e "${MUTED}OpenCode includes free models, to start:${NC}"
echo -e "cd <project> ${MUTED}# Open directory${NC}"
echo -e "opencode ${MUTED}# Run command${NC}"
echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
echo -e ""
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-cieNNPXZd4Bg9bZtRq2H8L99e24U8p5d+d76SE7SeJc="
"nodeModules": "sha256-HyH219sZn4gOPyVg/bij7K3mfZ0MnBSM/7NmsOyrD4o="
}
+3 -2
View File
@@ -30,7 +30,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.5.5",
"@pierre/precision-diffs": "0.6.0-beta.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.97",
@@ -63,7 +63,8 @@
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*"
"@opencode-ai/sdk": "workspace:*",
"typescript": "catalog:"
},
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.112",
"version": "1.0.127",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -36,6 +36,7 @@ ${body.email}`.trim()
to: "contact@anoma.ly",
subject: `Enterprise Inquiry from ${body.name}`,
body: emailContent,
replyTo: body.email,
})
return Response.json({ success: true, message: "Form submitted successfully" }, { status: 200 })
@@ -14,7 +14,7 @@ import "./workspace-picker.css"
const getWorkspaces = query(async () => {
"use server"
return withActor(async () => {
return Database.transaction((tx) =>
return Database.use((tx) =>
tx
.select({
id: WorkspaceTable.id,
@@ -13,13 +13,20 @@ import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
import {
createBodyConverter,
createStreamPartConverter,
createResponseConverter,
ProviderHelper,
UsageInfo,
} from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter } from "./rateLimiter"
import { createDataDumper } from "./dataDumper"
import { createTrialLimiter } from "./trialLimiter"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@@ -62,11 +69,13 @@ export async function handler(
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId)
const trialLimiter = createTrialLimiter(modelInfo.trial?.limit, ip)
const isTrial = await trialLimiter?.isTrial()
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
await rateLimiter?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(zenData, modelInfo, sessionId, retry)
const providerInfo = selectProvider(zenData, modelInfo, sessionId, isTrial ?? false, retry)
const authInfo = await authenticate(modelInfo, providerInfo)
validateBilling(authInfo, modelInfo)
validateModelSettings(authInfo)
@@ -136,8 +145,10 @@ export async function handler(
logger.debug("RESPONSE: " + body)
dataDumper?.provideResponse(body)
dataDumper?.flush()
const tokensInfo = providerInfo.normalizeUsage(json.usage)
await trialLimiter?.track(tokensInfo)
await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo)
return new Response(body, {
status: res.status,
@@ -169,7 +180,9 @@ export async function handler(
await rateLimiter?.track()
const usage = usageParser.retrieve()
if (usage) {
await trackUsage(authInfo, modelInfo, providerInfo, usage)
const tokensInfo = providerInfo.normalizeUsage(usage)
await trialLimiter?.track(tokensInfo)
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo)
}
c.close()
@@ -275,8 +288,18 @@ export async function handler(
return { id: modelId, ...modelData }
}
function selectProvider(zenData: ZenData, modelInfo: ModelInfo, sessionId: string, retry: RetryOptions) {
function selectProvider(
zenData: ZenData,
modelInfo: ModelInfo,
sessionId: string,
isTrial: boolean,
retry: RetryOptions,
) {
const provider = (() => {
if (isTrial) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
}
if (retry.retryCount === MAX_RETRIES) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
}
@@ -432,9 +455,14 @@ export async function handler(
providerInfo.apiKey = authInfo.provider.credentials
}
async function trackUsage(authInfo: AuthInfo, modelInfo: ModelInfo, providerInfo: ProviderInfo, usage: any) {
async function trackUsage(
authInfo: AuthInfo,
modelInfo: ModelInfo,
providerInfo: ProviderInfo,
usageInfo: UsageInfo,
) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
providerInfo.normalizeUsage(usage)
usageInfo
const modelCost =
modelInfo.cost200K &&
@@ -24,6 +24,15 @@ import {
toOaCompatibleResponse,
} from "./openai-compatible"
export type UsageInfo = {
inputTokens: number
outputTokens: number
reasoningTokens?: number
cacheReadTokens?: number
cacheWrite5mTokens?: number
cacheWrite1hTokens?: number
}
export type ProviderHelper = {
format: ZenData.Format
modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string
@@ -34,14 +43,7 @@ export type ProviderHelper = {
parse: (chunk: string) => void
retrieve: () => any
}
normalizeUsage: (usage: any) => {
inputTokens: number
outputTokens: number
reasoningTokens?: number
cacheReadTokens?: number
cacheWrite5mTokens?: number
cacheWrite1hTokens?: number
}
normalizeUsage: (usage: any) => UsageInfo
}
export interface CommonMessage {
@@ -0,0 +1,43 @@
import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { UsageInfo } from "./provider/provider"
export function createTrialLimiter(limit: number | undefined, ip: string) {
if (!limit) return
if (!ip) return
let trial: boolean
return {
isTrial: async () => {
const data = await Database.use((tx) =>
tx
.select({
usage: IpTable.usage,
})
.from(IpTable)
.where(eq(IpTable.ip, ip))
.then((rows) => rows[0]),
)
trial = (data?.usage ?? 0) < limit
return trial
},
track: async (usageInfo: UsageInfo) => {
if (!trial) return
const usage =
usageInfo.inputTokens +
usageInfo.outputTokens +
(usageInfo.reasoningTokens ?? 0) +
(usageInfo.cacheReadTokens ?? 0) +
(usageInfo.cacheWrite5mTokens ?? 0) +
(usageInfo.cacheWrite1hTokens ?? 0)
await Database.use((tx) =>
tx
.insert(IpTable)
.values({ ip, usage })
.onDuplicateKeyUpdate({ set: { usage: sql`${IpTable.usage} + ${usage}` } }),
)
},
}
}
@@ -0,0 +1,8 @@
CREATE TABLE `ip` (
`ip` varchar(45) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`usage` int,
CONSTRAINT `ip_ip_pk` PRIMARY KEY(`ip`)
);
@@ -0,0 +1,981 @@
{
"version": "5",
"dialect": "mysql",
"id": "9d5d9885-7ec5-45f6-ac53-45a8e25dede7",
"prevId": "8b7fa839-a088-408e-84a4-1a07325c0290",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"account_id_pk": {
"name": "account_id_pk",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"auth": {
"name": "auth",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "enum('email','github','google')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"provider": {
"name": "provider",
"columns": ["provider", "subject"],
"isUnique": true
},
"account_id": {
"name": "account_id",
"columns": ["account_id"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"auth_id_pk": {
"name": "auth_id_pk",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"billing": {
"name": "billing",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_id": {
"name": "payment_method_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_type": {
"name": "payment_method_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_last4": {
"name": "payment_method_last4",
"type": "varchar(4)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monthly_limit": {
"name": "monthly_limit",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monthly_usage": {
"name": "monthly_usage",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_monthly_usage_updated": {
"name": "time_monthly_usage_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload": {
"name": "reload",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload_trigger": {
"name": "reload_trigger",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload_amount": {
"name": "reload_amount",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload_error": {
"name": "reload_error",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_reload_error": {
"name": "time_reload_error",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_reload_locked_till": {
"name": "time_reload_locked_till",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"global_customer_id": {
"name": "global_customer_id",
"columns": ["customer_id"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"billing_workspace_id_id_pk": {
"name": "billing_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"payment": {
"name": "payment",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"invoice_id": {
"name": "invoice_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_id": {
"name": "payment_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"amount": {
"name": "amount",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_refunded": {
"name": "time_refunded",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"payment_workspace_id_id_pk": {
"name": "payment_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"usage": {
"name": "usage",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"input_tokens": {
"name": "input_tokens",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"output_tokens": {
"name": "output_tokens",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reasoning_tokens": {
"name": "reasoning_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_read_tokens": {
"name": "cache_read_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_write_5m_tokens": {
"name": "cache_write_5m_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_write_1h_tokens": {
"name": "cache_write_1h_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cost": {
"name": "cost",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"usage_workspace_id_id_pk": {
"name": "usage_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"ip": {
"name": "ip",
"columns": {
"ip": {
"name": "ip",
"type": "varchar(45)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage": {
"name": "usage",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"ip_ip_pk": {
"name": "ip_ip_pk",
"columns": ["ip"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"key": {
"name": "key",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_used": {
"name": "time_used",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"global_key": {
"name": "global_key",
"columns": ["key"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"key_workspace_id_id_pk": {
"name": "key_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"model": {
"name": "model",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"model_workspace_model": {
"name": "model_workspace_model",
"columns": ["workspace_id", "model"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"model_workspace_id_id_pk": {
"name": "model_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"provider": {
"name": "provider",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credentials": {
"name": "credentials",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"workspace_provider": {
"name": "workspace_provider",
"columns": ["workspace_id", "provider"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"provider_workspace_id_id_pk": {
"name": "provider_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_seen": {
"name": "time_seen",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"color": {
"name": "color",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('admin','member')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monthly_limit": {
"name": "monthly_limit",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monthly_usage": {
"name": "monthly_usage",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_monthly_usage_updated": {
"name": "time_monthly_usage_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_account_id": {
"name": "user_account_id",
"columns": ["workspace_id", "account_id"],
"isUnique": true
},
"user_email": {
"name": "user_email",
"columns": ["workspace_id", "email"],
"isUnique": true
},
"global_account_id": {
"name": "global_account_id",
"columns": ["account_id"],
"isUnique": false
},
"global_email": {
"name": "global_email",
"columns": ["email"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_workspace_id_id_pk": {
"name": "user_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"workspace": {
"name": "workspace",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"slug": {
"name": "slug",
"columns": ["slug"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"workspace_id": {
"name": "workspace_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
@@ -267,6 +267,13 @@
"when": 1761928273807,
"tag": "0037_messy_jackal",
"breakpoints": true
},
{
"idx": 38,
"version": "5",
"when": 1764110043942,
"tag": "0038_famous_magik",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.112",
"version": "1.0.127",
"private": true,
"type": "module",
"dependencies": {
+5 -6
View File
@@ -11,7 +11,7 @@ export namespace Account {
id: z.string().optional(),
}),
async (input) =>
Database.transaction(async (tx) => {
Database.use(async (tx) => {
const id = input.id ?? Identifier.create("account")
await tx.insert(AccountTable).values({
id,
@@ -21,13 +21,12 @@ export namespace Account {
)
export const fromID = fn(z.string(), async (id) =>
Database.transaction(async (tx) => {
return tx
Database.use((tx) =>
tx
.select()
.from(AccountTable)
.where(eq(AccountTable.id, id))
.execute()
.then((rows) => rows[0])
}),
.then((rows) => rows[0]),
),
)
}
+2
View File
@@ -22,6 +22,7 @@ export namespace AWS {
to: z.string(),
subject: z.string(),
body: z.string(),
replyTo: z.string().optional(),
}),
async (input) => {
const res = await createClient().fetch("https://email.us-east-1.amazonaws.com/v2/email/outbound-emails", {
@@ -35,6 +36,7 @@ export namespace AWS {
Destination: {
ToAddresses: [input.to],
},
...(input.replyTo && { ReplyToAddresses: [input.replyTo] }),
Content: {
Simple: {
Subject: {
+6
View File
@@ -24,6 +24,12 @@ export namespace ZenData {
cost: ModelCostSchema,
cost200K: ModelCostSchema.optional(),
allowAnonymous: z.boolean().optional(),
trial: z
.object({
limit: z.number(),
provider: z.string(),
})
.optional(),
rateLimit: z.number().optional(),
fallbackProvider: z.string().optional(),
providers: z.array(
+1 -1
View File
@@ -47,7 +47,7 @@ export namespace Provider {
}),
async ({ provider }) => {
Actor.assertAdmin()
return Database.transaction((tx) =>
Database.use((tx) =>
tx
.delete(ProviderTable)
.where(and(eq(ProviderTable.provider, provider), eq(ProviderTable.workspaceID, Actor.workspace()))),
@@ -0,0 +1,12 @@
import { mysqlTable, int, primaryKey, varchar } from "drizzle-orm/mysql-core"
import { timestamps } from "../drizzle/types"
export const IpTable = mysqlTable(
"ip",
{
ip: varchar("ip", { length: 45 }).notNull(),
...timestamps,
usage: int("usage"),
},
(table) => [primaryKey({ columns: [table.ip] })],
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.112",
"version": "1.0.127",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -194,7 +194,7 @@ export default {
// Get workspace
await Actor.provide("account", { accountID, email }, async () => {
await User.joinInvitedWorkspaces()
const workspaces = await Database.transaction(async (tx) =>
const workspaces = await Database.use((tx) =>
tx
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.112",
"version": "1.0.127",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+2 -1
View File
@@ -9,7 +9,8 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
</head>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.112",
"version": "1.0.127",
"description": "",
"type": "module",
"scripts": {
@@ -1,5 +1,5 @@
import { useLocal, type LocalFile } from "@/context/local"
import { Collapsible } from "@/ui"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
@@ -76,6 +76,7 @@ export default function FileTree(props: {
<Switch>
<Match when={node.type === "directory"}>
<Collapsible
variant="ghost"
class="w-full"
forceMount={false}
// open={local.file.node(node.path)?.expanded}
@@ -1,7 +1,6 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
import { DateTime } from "luxon"
@@ -16,6 +15,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
interface PromptInputProps {
class?: string
+4 -28
View File
@@ -1,4 +1,3 @@
import type { Part } from "@opencode-ai/sdk"
import { produce } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@opencode-ai/util/binary"
@@ -34,29 +33,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
const sanitize = (text: string) => text.replace(sanitizer(), "")
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
const sanitizePart = (part: Part) => {
if (part.type === "tool") {
if (part.state.status === "completed" || part.state.status === "error") {
for (const key in part.state.metadata) {
if (typeof part.state.metadata[key] === "string") {
part.state.metadata[key] = sanitize(part.state.metadata[key] as string)
}
}
for (const key in part.state.input) {
if (typeof part.state.input[key] === "string") {
part.state.input[key] = sanitize(part.state.input[key] as string)
}
}
if ("error" in part.state) {
part.state.error = sanitize(part.state.error as string)
}
}
}
return part
}
return {
data: store,
@@ -88,10 +65,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts
.slice()
.map(sanitizePart)
.sort((a, b) => a.id.localeCompare(b.id))
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
draft.session_diff[sessionID] = diff.data ?? []
}),
@@ -105,7 +79,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
load,
absolute,
sanitize,
get directory() {
return store.path.directory
},
}
},
})
@@ -21,7 +21,7 @@ export default function Layout(props: ParentProps) {
{iife(() => {
const sync = useSync()
return (
<DataProvider data={sync.data}>
<DataProvider data={sync.data} directory={directory()}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
+2 -1
View File
@@ -1,8 +1,9 @@
import { useGlobalSync } from "@/context/global-sync"
import { base64Encode, getFilename } from "@/utils"
import { base64Encode } from "@/utils"
import { For } from "solid-js"
import { A } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { getFilename } from "@opencode-ai/util/path"
export default function Home() {
const sync = useGlobalSync()
+2 -1
View File
@@ -3,7 +3,7 @@ import { DateTime } from "luxon"
import { A, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { base64Encode, getFilename } from "@/utils"
import { base64Encode } from "@/utils"
import { Mark } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -11,6 +11,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
export default function Layout(props: ParentProps) {
const params = useParams()
+34 -36
View File
@@ -1,7 +1,6 @@
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
import { PromptInput } from "@/components/prompt-input"
import { DateTime } from "luxon"
import { FileIcon } from "@opencode-ai/ui/file-icon"
@@ -13,7 +12,7 @@ import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Code } from "@opencode-ai/ui/code"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import {
@@ -30,6 +29,8 @@ import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
import { useSession } from "@/context/session"
import { useLayout } from "@/context/layout"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Diff } from "@opencode-ai/ui/diff"
export default function Page() {
const layout = useLayout()
@@ -333,43 +334,36 @@ export default function Page() {
flex: layout.review.state() === "pane",
}}
>
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-2xl mx-auto">
<div
classList={{
"relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true,
"max-w-146 mx-auto": !wide(),
}}
>
<Switch>
<Match when={session.id}>
<div class="flex items-start justify-start h-full min-h-0">
<Show when={session.messages.user().length > 1}>
<>
<MessageNav
class="@6xl:hidden mt-3 mr-8"
messages={session.messages.user()}
current={session.messages.active()}
onMessageSelect={session.messages.setActive}
size="compact"
working={session.working()}
/>
<MessageNav
classList={{
"hidden @6xl:flex": true,
"mt-0.5 mr-3 absolute right-full": wide(),
"mt-3 mr-8": !wide(),
}}
messages={session.messages.user()}
current={session.messages.active()}
onMessageSelect={session.messages.setActive}
size={wide() ? "normal" : "compact"}
working={session.working()}
/>
</>
</Show>
<SessionMessageRail
messages={session.messages.user()}
current={session.messages.active()}
onMessageSelect={session.messages.setActive}
working={session.working()}
wide={wide()}
/>
<SessionTurn
sessionID={session.id!}
messageID={session.messages.active()?.id!}
classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20" }}
classes={{
root: "pb-20 flex-1 min-w-0",
content: "pb-20",
container: "w-full " + (wide() ? "max-w-146 mx-auto px-6" : "pr-6 pl-18"),
}}
diffComponent={Diff}
/>
</div>
</Match>
<Match when={true}>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-146 mx-auto px-6">
<div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
@@ -390,12 +384,14 @@ export default function Page() {
</div>
</Match>
</Switch>
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
<div class="w-full max-w-146 px-6">
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</div>
</div>
</div>
<Show when={layout.review.state() === "pane" && session.diffs().length}>
@@ -411,6 +407,7 @@ export default function Page() {
container: "px-6",
}}
diffs={session.diffs()}
diffComponent={Diff}
actions={
<Tooltip value="Open in tab">
<IconButton
@@ -442,6 +439,7 @@ export default function Page() {
container: "px-6",
}}
diffs={session.diffs()}
diffComponent={Diff}
split
/>
</div>
@@ -498,7 +496,7 @@ export default function Page() {
</DragOverlay>
</DragDropProvider>
<Show when={session.layout.tabs.active}>
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<PromptInput
ref={(el) => {
inputRef = el
-62
View File
@@ -1,62 +0,0 @@
import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible"
import { Icon, IconProps } from "@opencode-ai/ui/icon"
import { splitProps } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
export interface CollapsibleProps extends ComponentProps<typeof KobalteCollapsible> {}
export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {}
export interface CollapsibleContentProps extends ComponentProps<typeof KobalteCollapsible.Content> {}
function CollapsibleRoot(props: CollapsibleProps) {
return <KobalteCollapsible forceMount {...props} />
}
function CollapsibleTrigger(props: CollapsibleTriggerProps) {
const [local, others] = splitProps(props, ["class"])
return (
<KobalteCollapsible.Trigger
classList={{
"w-full group/collapsible": true,
[local.class ?? ""]: !!local.class,
}}
{...others}
/>
)
}
function CollapsibleContent(props: ParentProps<CollapsibleContentProps>) {
const [local, others] = splitProps(props, ["class", "children"])
return (
<KobalteCollapsible.Content
classList={{
"h-0 overflow-hidden transition-all duration-100 ease-out": true,
"data-expanded:h-fit": true,
[local.class]: !!local.class,
}}
{...others}
>
{local.children}
</KobalteCollapsible.Content>
)
}
function CollapsibleArrow(props: Partial<IconProps>) {
const [local, others] = splitProps(props, ["class", "name"])
return (
<Icon
name={local.name ?? "chevron-right"}
classList={{
"flex-none text-text-muted transition-transform duration-100": true,
"group-data-[expanded]/collapsible:rotate-90": true,
[local.class ?? ""]: !!local.class,
}}
{...others}
/>
)
}
export const Collapsible = Object.assign(CollapsibleRoot, {
Trigger: CollapsibleTrigger,
Content: CollapsibleContent,
Arrow: CollapsibleArrow,
})
-6
View File
@@ -1,6 +0,0 @@
export {
Collapsible,
type CollapsibleProps,
type CollapsibleTriggerProps,
type CollapsibleContentProps,
} from "./collapsible"
-1
View File
@@ -1,3 +1,2 @@
export * from "./path"
export * from "./dom"
export * from "./encode"
-20
View File
@@ -1,20 +0,0 @@
import { useSync } from "@/context/sync"
export function getFilename(path: string) {
if (!path) return ""
const trimmed = path.replace(/[\/]+$/, "")
const parts = trimmed.split("/")
return parts[parts.length - 1] ?? ""
}
export function getDirectory(path: string) {
const sync = useSync()
const parts = path.split("/")
const dir = parts.slice(0, parts.length - 1).join("/")
return dir ? sync.sanitize(dir + "/") : ""
}
export function getFileExtension(path: string) {
const parts = path.split(".")
return parts[parts.length - 1]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.112",
"version": "1.0.127",
"private": true,
"type": "module",
"scripts": {
+76 -26
View File
@@ -1,8 +1,10 @@
import { FileDiff, Message, Model, Part, Session, SessionStatus } from "@opencode-ai/sdk"
import { fn } from "@opencode-ai/util/fn"
import { iife } from "@opencode-ai/util/iife"
import { Identifier } from "@opencode-ai/util/identifier"
import z from "zod"
import { Storage } from "./storage"
import { Binary } from "@opencode-ai/util/binary"
export namespace Share {
export const Info = z.object({
@@ -37,15 +39,15 @@ export namespace Share {
export type Data = z.infer<typeof Data>
export const create = fn(z.object({ sessionID: z.string() }), async (body) => {
const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_")
const info: Info = {
id: body.sessionID.slice(-8),
id: (isTest ? "test_" : "") + body.sessionID.slice(-8),
sessionID: body.sessionID,
secret: crypto.randomUUID(),
}
const exists = await get(info.id)
if (exists) throw new Errors.AlreadyExists(info.id)
await Storage.write(["share", info.id], info)
console.log("created share", info.id)
return info
})
@@ -58,30 +60,77 @@ export namespace Share {
if (!share) throw new Errors.NotFound(body.id)
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
await Storage.remove(["share", body.id])
const list = await Storage.list(["share_data", body.id])
const list = await Storage.list({ prefix: ["share_data", body.id] })
for (const item of list) {
await Storage.remove(item)
}
})
export async function data(id: string) {
const list = await Storage.list(["share_data", id])
const promises = []
for (const item of list) {
promises.push(
iife(async () => {
const [, , type] = item
return {
type: type as any,
data: await Storage.read<any>(item),
} as Data
}),
)
}
return await Promise.all(promises)
export const sync = fn(
z.object({
share: Info.pick({ id: true, secret: true }),
data: Data.array(),
}),
async (input) => {
const share = await get(input.share.id)
if (!share) throw new Errors.NotFound(input.share.id)
if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id)
await Storage.write(["share_event", input.share.id, Identifier.descending()], input.data)
},
)
type Compaction = {
event?: string
data: Data[]
}
export const sync = fn(
export async function data(shareID: string) {
console.log("reading compaction")
const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? {
data: [],
event: undefined,
}
console.log("reading pending events")
const list = await Storage.list({
prefix: ["share_event", shareID],
before: compaction.event,
}).then((x) => x.toReversed())
console.log("compacting", list.length)
if (list.length > 0) {
const data = await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) => x.flat())
for (const item of data) {
if (!item) continue
const key = (item: Data) => {
switch (item.type) {
case "session":
return "session"
case "message":
return `message/${item.data.id}`
case "part":
return `${item.data.messageID}/${item.data.id}`
case "session_diff":
return "session_diff"
case "model":
return "model"
}
}
const id = key(item)
const result = Binary.search(compaction.data, id, key)
if (result.found) {
compaction.data[result.index] = item
} else {
compaction.data.splice(result.index, 0, item)
}
}
compaction.event = list.at(-1)?.at(-1)
await Storage.write(["share_compaction", shareID], compaction)
}
return compaction.data
}
export const syncOld = fn(
z.object({
share: Info.pick({ id: true, secret: true }),
data: Data.array(),
@@ -98,15 +147,16 @@ export namespace Share {
case "session":
await Storage.write(["share_data", input.share.id, "session"], item.data)
break
case "message":
await Storage.write(["share_data", input.share.id, "message", item.data.id], item.data)
case "message": {
const data = item.data as Message
await Storage.write(["share_data", input.share.id, "message", data.id], item.data)
break
case "part":
await Storage.write(
["share_data", input.share.id, "part", item.data.messageID, item.data.id],
item.data,
)
}
case "part": {
const data = item.data as Part
await Storage.write(["share_data", input.share.id, "part", data.messageID, data.id], item.data)
break
}
case "session_diff":
await Storage.write(["share_data", input.share.id, "session_diff"], item.data)
break
+20 -5
View File
@@ -6,7 +6,7 @@ export namespace Storage {
read(path: string): Promise<string | undefined>
write(path: string, value: string): Promise<void>
remove(path: string): Promise<void>
list(prefix: string): Promise<string[]>
list(options?: { prefix?: string; limit?: number; after?: string; before?: string }): Promise<string[]>
}
function createAdapter(client: AwsClient, endpoint: string, bucket: string): Adapter {
@@ -37,8 +37,14 @@ export namespace Storage {
if (!response.ok) throw new Error(`Failed to remove ${path}: ${response.status}`)
},
async list(prefix: string): Promise<string[]> {
async list(options?: { prefix?: string; limit?: number; after?: string; before?: string }): Promise<string[]> {
const prefix = options?.prefix || ""
const params = new URLSearchParams({ "list-type": "2", prefix })
if (options?.limit) params.set("max-keys", options.limit.toString())
if (options?.after) {
const afterPath = prefix + options.after + ".json"
params.set("start-after", afterPath)
}
const response = await client.fetch(`${base}?${params}`)
if (!response.ok) throw new Error(`Failed to list ${prefix}: ${response.status}`)
const xml = await response.text()
@@ -48,6 +54,10 @@ export namespace Storage {
while ((match = regex.exec(xml)) !== null) {
keys.push(match[1])
}
if (options?.before) {
const beforePath = prefix + options.before + ".json"
return keys.filter((key) => key < beforePath)
}
return keys
},
}
@@ -98,9 +108,14 @@ export namespace Storage {
return adapter().remove(resolve(key))
}
export async function list(prefix: string[]) {
const p = prefix.join("/") + (prefix.length ? "/" : "")
const result = await adapter().list(p)
export async function list(options?: { prefix?: string[]; limit?: number; after?: string; before?: string }) {
const p = options?.prefix ? options.prefix.join("/") + (options.prefix.length ? "/" : "") : ""
const result = await adapter().list({
prefix: p,
limit: options?.limit,
after: options?.after,
before: options?.before,
})
return result.map((x) => x.replace(/\.json$/, "").split("/"))
}
+2
View File
@@ -9,6 +9,8 @@ export default createHandler(() => (
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
{assets}
+255 -225
View File
@@ -7,16 +7,21 @@ import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } fro
import { Share } from "~/core/share"
import { Logo, Mark } from "@opencode-ai/ui/logo"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { createDefaultOptions } from "@opencode-ai/ui/pierre"
import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary"
import { NamedError } from "@opencode-ai/util/error"
import { DateTime } from "luxon"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { createStore } from "solid-js/store"
import z from "zod"
import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
import { Diff } from "@opencode-ai/ui/diff-ssr"
import { clientOnly } from "@solidjs/start"
const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
const SessionDataMissingError = NamedError.create(
"SessionDataMissingError",
@@ -40,6 +45,9 @@ const getData = query(async (shareID) => {
session_diff_preload: {
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
}
session_diff_preload_split: {
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
}
session_status: {
[sessionID: string]: SessionStatus
}
@@ -61,6 +69,9 @@ const getData = query(async (shareID) => {
session_diff_preload: {
[share.sessionID]: [],
},
session_diff_preload_split: {
[share.sessionID]: [],
},
session_status: {
[share.sessionID]: {
type: "idle",
@@ -77,29 +88,28 @@ const getData = query(async (shareID) => {
break
case "session_diff":
result.session_diff[share.sessionID] = item.data
result.session_diff_preload[share.sessionID] = await Promise.all(
item.data.map(async (diff) =>
preloadMultiFileDiff<any>({
oldFile: { name: diff.file, contents: diff.before },
newFile: { name: diff.file, contents: diff.after },
options: {
theme: "OpenCode",
themeType: "system",
disableLineNumbers: false,
overflow: "wrap",
diffStyle: "unified",
diffIndicators: "bars",
disableBackground: false,
expansionLineCount: 20,
lineDiffType: "none",
maxLineDiffLength: 1000,
maxLineLengthForHighlighting: 1000,
disableFileHeader: true,
},
// annotations,
}),
),
)
await Promise.all([
Promise.all(
item.data.map(async (diff) =>
preloadMultiFileDiff<any>({
oldFile: { name: diff.file, contents: diff.before },
newFile: { name: diff.file, contents: diff.after },
options: createDefaultOptions("unified"),
// annotations,
}),
),
).then((r) => (result.session_diff_preload[share.sessionID] = r)),
Promise.all(
item.data.map(async (diff) =>
preloadMultiFileDiff<any>({
oldFile: { name: diff.file, contents: diff.before },
newFile: { name: diff.file, contents: diff.after },
options: createDefaultOptions("split"),
// annotations,
}),
),
).then((r) => (result.session_diff_preload_split[share.sessionID] = r)),
])
break
case "message":
result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
@@ -141,219 +151,239 @@ export default function () {
}}
>
<Show when={data()}>
{(data) => (
<DataProvider data={data()}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
const info = createMemo(() => data().session[match().index])
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => b.time.created - a.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
{(data) => {
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
const info = createMemo(() => data().session[match().index])
return (
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => b.time.created - a.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
}
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const title = () => (
<div class="flex flex-col gap-4 shrink-0">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" />
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
const title = () => (
<div class="flex flex-col gap-4">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" />
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
)
const turns = () => (
<div class="relative mt-2 pt-6 pb-8 px-4 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
{title()}
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
}}
/>
)}
</For>
</div>
<div class="flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
)
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div class="hidden md:flex w-full flex-1 min-h-0">
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
"px-21 @4xl:px-6 max-w-2xl": !wide(),
"px-6 max-w-2xl": wide(),
}}
>
{title()}
<div class="flex items-start justify-start h-full min-h-0">
<Show when={messages().length > 1}>
<>
<div class="md:hidden absolute right-full">
<MessageNav
class="mt-2 mr-3"
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
size="compact"
/>
</div>
<div
classList={{
"hidden md:block": true,
"absolute right-[90%]": !wide(),
"absolute right-full": wide(),
}}
>
<MessageNav
classList={{
"mt-2.5 mr-3": !wide(),
"mt-0.5 mr-8": wide(),
}}
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
size={wide() ? "normal" : "compact"}
/>
</div>
</>
</Show>
const turns = () => (
<div class="relative mt-2 pt-6 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
>
<div class="flex items-center justify-center pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
</div>
<Show when={diffs().length > 0}>
<div class="relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
diffs={diffs()}
messageID={message.id}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container: "px-4",
}}
diffComponent={ClientOnlyDiff}
/>
</div>
</Show>
)}
</For>
</div>
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs class="md:hidden">
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
5 Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content forceMount value="review" class="!overflow-hidden hidden data-[selected]:block">
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<SessionReview
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
}}
/>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div class="md:hidden !overflow-hidden">{turns()}</div>
</Match>
</Switch>
</div>
</div>
)
})}
</DataProvider>
)}
)
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div classList={{ "hidden w-full flex-1 min-h-0": true, "md:flex": wide(), "lg:flex": !wide() }}>
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"mx-auto max-w-146": !wide(),
}}
>
<div
classList={{
"w-full flex justify-start items-start min-w-0": true,
"max-w-146 mx-auto px-6": wide(),
"pr-6 pl-18": !wide(),
}}
>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between items-start",
container: "w-full pb-20 " + (wide() ? "max-w-146 mx-auto px-6" : "pr-6 pl-18"),
}}
diffComponent={ClientOnlyDiff}
>
<div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
</div>
<Show when={diffs().length > 0}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
diffComponent={Diff}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
diffComponent={Diff}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
</div>
</Show>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
5 Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<SessionReview
diffs={diffs()}
diffComponent={Diff}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
}}
/>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}>
{turns()}
</div>
</Match>
</Switch>
</div>
</div>
)
})}
</DataProvider>
)
}}
</Show>
</ErrorBoundary>
)
+40
View File
@@ -0,0 +1,40 @@
import { Share } from "./src/core/share"
import { Storage } from "./src/core/storage"
async function test() {
const shareInfo = await Share.create({ sessionID: "test-debug-" + Date.now() })
const batch1: Share.Data[] = [
{ type: "part", data: { id: "part1", sessionID: "session1", messageID: "msg1", type: "text", text: "Hello" } },
]
const batch2: Share.Data[] = [
{
type: "part",
data: { id: "part1", sessionID: "session1", messageID: "msg1", type: "text", text: "Hello Updated" },
},
]
await Share.sync({
share: { id: shareInfo.id, secret: shareInfo.secret },
data: batch1,
})
await Share.sync({
share: { id: shareInfo.id, secret: shareInfo.secret },
data: batch2,
})
const events = await Storage.list({ prefix: ["share_event", shareInfo.id] })
console.log("Events (raw):", events)
console.log("Events (reversed):", events.toReversed())
for (const event of events.toReversed()) {
const data = await Storage.read(event)
console.log("Event data (reversed order):", event, data)
}
await Share.remove({ id: shareInfo.id, secret: shareInfo.secret })
}
test()
+262
View File
@@ -0,0 +1,262 @@
import { describe, expect, test, afterAll } from "bun:test"
import { Share } from "../../src/core/share"
import { Storage } from "../../src/core/storage"
import { Identifier } from "@opencode-ai/util/identifier"
describe.concurrent("core.share", () => {
test("should create a share", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
expect(share.sessionID).toBe(sessionID)
expect(share.secret).toBeDefined()
await Share.remove({ id: share.id, secret: share.secret })
})
test("should sync data to a share", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data: Share.Data[] = [
{
type: "part",
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
},
]
await Share.sync({
share: { id: share.id, secret: share.secret },
data,
})
const events = await Storage.list({ prefix: ["share_event", share.id] })
expect(events.length).toBe(1)
await Share.remove({ id: share.id, secret: share.secret })
})
test("should sync multiple batches of data", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data1: Share.Data[] = [
{
type: "part",
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
},
]
const data2: Share.Data[] = [
{
type: "part",
data: { id: "part2", sessionID, messageID: "msg1", type: "text", text: "World" },
},
]
await Share.sync({
share: { id: share.id, secret: share.secret },
data: data1,
})
await Share.sync({
share: { id: share.id, secret: share.secret },
data: data2,
})
const events = await Storage.list({ prefix: ["share_event", share.id] })
expect(events.length).toBe(2)
await Share.remove({ id: share.id, secret: share.secret })
})
test("should retrieve synced data", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data: Share.Data[] = [
{
type: "part",
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
},
{
type: "part",
data: { id: "part2", sessionID, messageID: "msg1", type: "text", text: "World" },
},
]
await Share.sync({
share: { id: share.id, secret: share.secret },
data,
})
const result = await Share.data(share.id)
expect(result.length).toBe(2)
expect(result[0].type).toBe("part")
expect(result[1].type).toBe("part")
await Share.remove({ id: share.id, secret: share.secret })
})
test("should retrieve data from multiple syncs", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data1: Share.Data[] = [
{
type: "part",
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
},
]
const data2: Share.Data[] = [
{
type: "part",
data: { id: "part2", sessionID, messageID: "msg2", type: "text", text: "World" },
},
]
const data3: Share.Data[] = [
{ type: "part", data: { id: "part3", sessionID, messageID: "msg3", type: "text", text: "!" } },
]
await Share.sync({
share: { id: share.id, secret: share.secret },
data: data1,
})
await Share.sync({
share: { id: share.id, secret: share.secret },
data: data2,
})
await Share.sync({
share: { id: share.id, secret: share.secret },
data: data3,
})
const result = await Share.data(share.id)
expect(result.length).toBe(3)
const parts = result.filter((d) => d.type === "part")
expect(parts.length).toBe(3)
await Share.remove({ id: share.id, secret: share.secret })
})
test("should return latest data when syncing duplicate parts", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data1: Share.Data[] = [
{
type: "part",
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
},
]
const data2: Share.Data[] = [
{
type: "part",
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello Updated" },
},
]
await Share.sync({
share: { id: share.id, secret: share.secret },
data: data1,
})
await Share.sync({
share: { id: share.id, secret: share.secret },
data: data2,
})
const result = await Share.data(share.id)
expect(result.length).toBe(1)
const [first] = result
expect(first.type).toBe("part")
expect(first.type === "part" && first.data.type === "text" && first.data.text).toBe("Hello Updated")
await Share.remove({ id: share.id, secret: share.secret })
})
test("should return empty array for share with no data", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const result = await Share.data(share.id)
expect(result).toEqual([])
await Share.remove({ id: share.id, secret: share.secret })
})
test("should throw error for invalid secret", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data: Share.Data[] = [
{
type: "part",
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Test" },
},
]
expect(async () => {
await Share.sync({
share: { id: share.id, secret: "invalid-secret" },
data,
})
}).toThrow()
await Share.remove({ id: share.id, secret: share.secret })
})
test("should throw error for non-existent share", async () => {
const sessionID = Identifier.descending()
const data: Share.Data[] = [
{
type: "part",
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Test" },
},
]
expect(async () => {
await Share.sync({
share: { id: "non-existent-id", secret: "some-secret" },
data,
})
}).toThrow()
})
test("should handle different data types", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data: Share.Data[] = [
{ type: "session", data: { id: sessionID, status: "running" } as any },
{ type: "message", data: { id: "msg1", sessionID } as any },
{
type: "part",
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
},
]
await Share.sync({
share: { id: share.id, secret: share.secret },
data,
})
const result = await Share.data(share.id)
expect(result.length).toBe(3)
expect(result.some((d) => d.type === "session")).toBe(true)
expect(result.some((d) => d.type === "message")).toBe(true)
expect(result.some((d) => d.type === "part")).toBe(true)
await Share.remove({ id: share.id, secret: share.secret })
})
})
@@ -0,0 +1,64 @@
import { describe, expect, test, afterAll } from "bun:test"
import { Storage } from "../../src/core/storage"
describe("core.storage", () => {
test("should list files with after and before range", async () => {
await Storage.write(["test", "users", "user1"], { name: "user1" })
await Storage.write(["test", "users", "user2"], { name: "user2" })
await Storage.write(["test", "users", "user3"], { name: "user3" })
await Storage.write(["test", "users", "user4"], { name: "user4" })
await Storage.write(["test", "users", "user5"], { name: "user5" })
const result = await Storage.list({ prefix: ["test", "users"], after: "user2", before: "user4" })
expect(result).toEqual([["test", "users", "user3"]])
})
test("should list files with after only", async () => {
const result = await Storage.list({ prefix: ["test", "users"], after: "user3" })
expect(result).toEqual([
["test", "users", "user4"],
["test", "users", "user5"],
])
})
test("should list files with limit", async () => {
const result = await Storage.list({ prefix: ["test", "users"], limit: 3 })
expect(result).toEqual([
["test", "users", "user1"],
["test", "users", "user2"],
["test", "users", "user3"],
])
})
test("should list all files without prefix", async () => {
const result = await Storage.list()
expect(result.length).toBeGreaterThan(0)
})
test("should list all files with prefix", async () => {
const result = await Storage.list({ prefix: ["test", "users"] })
expect(result).toEqual([
["test", "users", "user1"],
["test", "users", "user2"],
["test", "users", "user3"],
["test", "users", "user4"],
["test", "users", "user5"],
])
})
afterAll(async () => {
const testFiles = await Storage.list({ prefix: ["test"] })
for (const file of testFiles) {
await Storage.remove(file)
}
const remainingFiles = await Storage.list({ prefix: ["test"] })
expect(remainingFiles).toEqual([])
})
})
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The AI coding agent built for the terminal"
version = "1.0.112"
version = "1.0.127"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.112/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.127/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.112/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.127/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.112/opencode-linux-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.127/opencode-linux-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.112/opencode-linux-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.127/opencode-linux-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.112/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.127/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.112",
"version": "1.0.127",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+10 -8
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.112",
"version": "1.0.127",
"name": "opencode",
"type": "module",
"private": true,
@@ -43,13 +43,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.57",
"@ai-sdk/anthropic": "2.0.45",
"@ai-sdk/anthropic": "2.0.50",
"@ai-sdk/azure": "2.0.73",
"@ai-sdk/google": "2.0.42",
"@ai-sdk/google-vertex": "3.0.74",
"@ai-sdk/google": "2.0.44",
"@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/mcp": "0.0.8",
"@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -61,9 +63,9 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.2.5",
"@opentui/core": "0.1.50",
"@opentui/solid": "0.1.50",
"@openrouter/ai-sdk-provider": "1.2.8",
"@opentui/core": "0.1.54",
"@opentui/solid": "0.1.54",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -82,7 +84,7 @@
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.5",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
"solid-js": "catalog:",
+36 -2
View File
@@ -102,8 +102,7 @@ export namespace Agent {
const result: Record<string, Info> = {
general: {
name: "general",
description:
"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
tools: {
todoread: false,
todowrite: false,
@@ -114,6 +113,41 @@ export namespace Agent {
mode: "subagent",
builtIn: true,
},
explore: {
name: "explore",
tools: {
todoread: false,
todowrite: false,
edit: false,
write: false,
...defaultTools,
},
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: [
`You are a file search specialist. You excel at thoroughly navigating and exploring codebases.`,
``,
`Your strengths:`,
`- Rapidly finding files using glob patterns`,
`- Searching code and text with powerful regex patterns`,
`- Reading and analyzing file contents`,
``,
`Guidelines:`,
`- Use Glob for broad file pattern matching`,
`- Use Grep for searching file contents with regex`,
`- Use Read when you know the specific file path you need to read`,
`- Use Bash for file operations like copying, moving, or listing directory contents`,
`- Adapt your search approach based on the thoroughness level specified by the caller`,
`- Return file paths as absolute paths in your final response`,
`- For clear communication, avoid using emojis`,
`- Do not create any files, or run bash commands that modify the user's system state in any way`,
``,
`Complete the user's search request efficiently and report your findings clearly.`,
].join("\n"),
options: {},
permission: agentPermission,
mode: "subagent",
builtIn: true,
},
build: {
name: "build",
tools: { ...defaultTools },
+31 -8
View File
@@ -7,19 +7,35 @@ import { GlobalBus } from "./global"
export namespace Bus {
const log = Log.create({ service: "bus" })
type Subscription = (event: any) => void
const state = Instance.state(() => {
const subscriptions = new Map<any, Subscription[]>()
return {
subscriptions,
}
})
const disposedEventType = "server.instance.disposed"
export type EventDefinition = ReturnType<typeof event>
const registry = new Map<string, EventDefinition>()
const state = Instance.state(
() => {
const subscriptions = new Map<any, Subscription[]>()
return {
subscriptions,
}
},
async (entry) => {
const wildcard = entry.subscriptions.get("*")
if (!wildcard) return
const event = {
type: disposedEventType,
properties: {
directory: Instance.directory,
},
}
for (const sub of [...wildcard]) {
sub(event)
}
},
)
export function event<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
@@ -29,6 +45,13 @@ export namespace Bus {
return result
}
export const InstanceDisposed = event(
disposedEventType,
z.object({
directory: z.string(),
}),
)
export function payloads() {
return z
.discriminatedUnion(
+17 -1
View File
@@ -6,6 +6,7 @@ import { ModelsDev } from "../../provider/models"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Config } from "../../config/config"
import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
@@ -103,7 +104,22 @@ export const AuthLoginCommand = cmd({
return
}
await ModelsDev.refresh().catch(() => {})
const providers = await ModelsDev.get()
const config = await Config.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await ModelsDev.get().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
return filtered
})
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
+52 -8
View File
@@ -7,7 +7,7 @@ import { graphql } from "@octokit/graphql"
import * as core from "@actions/core"
import * as github from "@actions/github"
import type { Context } from "@actions/github/lib/context"
import type { IssueCommentEvent } from "@octokit/webhooks-types"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models"
@@ -328,6 +328,8 @@ export const GithubInstallCommand = cmd({
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
opencode:
@@ -378,7 +380,7 @@ export const GithubRunCommand = cmd({
const isMock = args.token || args.event
const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
if (context.eventName !== "issue_comment") {
if (context.eventName !== "issue_comment" && context.eventName !== "pull_request_review_comment") {
core.setFailed(`Unsupported event type: ${context.eventName}`)
process.exit(1)
}
@@ -387,9 +389,14 @@ export const GithubRunCommand = cmd({
const runId = normalizeRunId()
const share = normalizeShare()
const { owner, repo } = context.repo
const payload = context.payload as IssueCommentEvent
const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
const actor = context.actor
const issueId = payload.issue.number
const issueId =
context.eventName === "pull_request_review_comment"
? (payload as PullRequestReviewCommentEvent).pull_request.number
: (payload as IssueCommentEvent).issue.number
const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai"
@@ -434,7 +441,7 @@ export const GithubRunCommand = cmd({
// 1. Issue
// 2. Local PR
// 3. Fork PR
if (payload.issue.pull_request) {
if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
const prData = await fetchPR()
// Local PR
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
@@ -531,11 +538,45 @@ export const GithubRunCommand = cmd({
throw new Error(`Invalid share value: ${value}. Share must be a boolean.`)
}
function isIssueCommentEvent(
event: IssueCommentEvent | PullRequestReviewCommentEvent,
): event is IssueCommentEvent {
return "issue" in event
}
function getReviewCommentContext() {
if (context.eventName !== "pull_request_review_comment") {
return null
}
const reviewPayload = payload as PullRequestReviewCommentEvent
return {
file: reviewPayload.comment.path,
diffHunk: reviewPayload.comment.diff_hunk,
line: reviewPayload.comment.line,
originalLine: reviewPayload.comment.original_line,
position: reviewPayload.comment.position,
commitId: reviewPayload.comment.commit_id,
originalCommitId: reviewPayload.comment.original_commit_id,
}
}
async function getUserPrompt() {
const reviewContext = getReviewCommentContext()
let prompt = (() => {
const body = payload.comment.body.trim()
if (body === "/opencode" || body === "/oc") return "Summarize this thread"
if (body.includes("/opencode") || body.includes("/oc")) return body
if (body === "/opencode" || body === "/oc") {
if (reviewContext) {
return `Review this code change and suggest improvements for the commented lines:\n\nFile: ${reviewContext.file}\nLines: ${reviewContext.line}\n\n${reviewContext.diffHunk}`
}
return "Summarize this thread"
}
if (body.includes("/opencode") || body.includes("/oc")) {
if (reviewContext) {
return `${body}\n\nContext: You are reviewing a comment on file "${reviewContext.file}" at line ${reviewContext.line}.\n\nDiff context:\n${reviewContext.diffHunk}`
}
return body
}
throw new Error("Comments must mention `/opencode` or `/oc`")
})()
@@ -652,7 +693,10 @@ export const GithubRunCommand = cmd({
try {
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
} catch (e) {
return `Fix issue: ${payload.issue.title}`
const title = issueEvent
? issueEvent.issue.title
: (payload as PullRequestReviewCommentEvent).pull_request.title
return `Fix issue: ${title}`
}
}
+39 -54
View File
@@ -2,7 +2,7 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show } from "solid-js"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -30,6 +30,7 @@ import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -186,19 +187,27 @@ function App() {
})
})
let continued = false
createEffect(() => {
if (sync.status !== "complete") return
if (args.continue) {
const match = sync.data.session.at(0)?.id
if (match) {
route.navigate({
type: "session",
sessionID: match,
})
}
if (continued || sync.status !== "complete" || !args.continue) return
const match = sync.data.session.at(0)?.id
if (match) {
continued = true
route.navigate({ type: "session", sessionID: match })
}
})
createEffect(
on(
() => sync.status === "complete" && sync.data.provider.length === 0,
(isEmpty, wasEmpty) => {
// only trigger when we transition into an empty-provider state
if (!isEmpty || wasEmpty) return
dialog.replace(() => <DialogProviderList />)
},
),
)
command.register(() => [
{
title: "Switch session",
@@ -318,6 +327,15 @@ function App() {
},
category: "System",
},
{
title: "Open docs",
value: "docs.open",
onSelect: () => {
open("https://opencode.ai/docs").catch(() => {})
dialog.clear()
},
category: "System",
},
{
title: "Exit the app",
value: "app.exit",
@@ -360,8 +378,9 @@ function App() {
])
createEffect(() => {
const providerID = local.model.current().providerID
if (providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
const currentModel = local.model.current()
if (!currentModel) return
if (currentModel.providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
untrack(() => {
DialogAlert.show(
dialog,
@@ -455,48 +474,14 @@ function App() {
}
}}
>
<box flexDirection="column" flexGrow={1}>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
</box>
<box
height={1}
backgroundColor={theme.backgroundPanel}
flexDirection="row"
justifyContent="space-between"
flexShrink={0}
>
<box flexDirection="row">
<box flexDirection="row" backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>open</text>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
code{" "}
</text>
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box>
</box>
<Show when={false}>
<box flexDirection="row" flexShrink={0}>
<text fg={theme.textMuted} paddingRight={1}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
<text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}>
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>
</box>
</Show>
</box>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
</box>
)
}
@@ -1,10 +1,12 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda"
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { Keybind } from "@/util/keybind"
import { iife } from "@/util/iife"
export function DialogModel() {
const local = useLocal()
@@ -16,42 +18,86 @@ export function DialogModel() {
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected())
const providers = createDialogProviderOptions()
const options = createMemo(() => {
return [
...(showRecent()
? local.model.recent().flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
const query = ref()?.filter
const favorites = connected() ? local.model.favorite() : []
const recents = local.model.recent()
const recentList = recents
.filter((item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID))
.slice(0, 5)
const favoriteOptions = !query
? favorites.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
]
})
: []),
title: model.name ?? item.modelID,
description: provider.name,
category: "Favorites",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
const recentOptions = !query
? recentList.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
return [
...favoriteOptions,
...recentOptions,
...pipe(
sync.data.provider,
sortBy(
@@ -62,28 +108,47 @@ export function DialogModel() {
pipe(
provider.models,
entries(),
map(([model, info]) => ({
value: {
map(([model, info]) => {
const value = {
providerID: provider.id,
modelID: model,
},
title: info.name ?? model,
description: connected() ? provider.name : undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model,
},
{ recent: true },
}
return {
value,
title: info.name ?? model,
description: favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
},
})),
filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model,
},
{ recent: true },
)
},
}
}),
filter((x) => {
if (query) return true
const value = x.value
const inFavorites = favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inFavorites) return false
const inRecents = recents.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inRecents) return false
return true
}),
sortBy((x) => x.title),
),
),
@@ -108,11 +173,19 @@ export function DialogModel() {
keybind={[
{
keybind: { ctrl: true, name: "a", meta: false, shift: false, leader: false },
title: connected() ? "Connect provider" : "More providers",
title: connected() ? "Connect provider" : "View all providers",
onTrigger() {
dialog.replace(() => <DialogProvider />)
},
},
{
keybind: Keybind.parse("ctrl+f")[0],
title: "Favorite",
disabled: !connected(),
onTrigger: (option) => {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
]}
ref={setRef}
title="Select model"
@@ -26,13 +26,15 @@ export function createDialogProviderOptions() {
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({
title: provider.name,
value: provider.id,
footer: {
opencode: "Recommended",
anthropic: "Claude Max or API key",
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
@@ -85,7 +87,6 @@ export function createDialogProviderOptions() {
}
},
})),
sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99),
)
})
return options
@@ -197,11 +198,24 @@ function ApiMethod(props: ApiMethodProps) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const { theme } = useTheme()
return (
<DialogPrompt
title={props.title}
placeholder="API key"
description={
props.providerID === "opencode" ? (
<box gap={1}>
<text fg={theme.textMuted}>
OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key.
</text>
<text>
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
</text>
</box>
) : undefined
}
onConfirm={async (value) => {
if (!value) return
sdk.client.auth.set({
@@ -81,6 +81,7 @@ export function Autocomplete(props: {
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId,
typeId: props.promptPartTypeId(),
})
@@ -238,7 +239,7 @@ export function Autocomplete(props: {
},
{
display: "/thinking",
description: "toggle thinking blocks",
description: "toggle thinking visibility",
onSelect: () => command.trigger("session.toggle.thinking"),
},
)
@@ -291,6 +292,11 @@ export function Autocomplete(props: {
description: "open editor",
onSelect: () => command.trigger("prompt.editor", "prompt"),
},
{
display: "/connect",
description: "connect to a provider",
onSelect: () => command.trigger("provider.connect"),
},
{
display: "/help",
description: "show help",
@@ -22,6 +22,9 @@ import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { createColors, createFrames } from "../../ui/spinner.ts"
import { useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { useToast } from "../../ui/toast"
export type PromptProps = {
sessionID?: string
@@ -50,12 +53,25 @@ export function Prompt(props: PromptProps) {
const sdk = useSDK()
const route = useRoute()
const sync = useSync()
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
const { theme, syntax } = useTheme()
function promptModelWarning() {
toast.show({
variant: "warning",
message: "Connect a provider to send prompts",
duration: 3000,
})
if (sync.data.provider.length === 0) {
dialog.replace(() => <DialogProviderConnect />)
}
}
const textareaKeybindings = createMemo(() => {
const newlineBindings = keybind.all.input_newline || []
const submitBindings = keybind.all.input_submit || []
@@ -310,6 +326,7 @@ export function Prompt(props: PromptProps) {
const extmarkId = input.extmarks.create({
start,
end,
virtual: true,
styleId,
typeId: promptPartTypeId,
})
@@ -387,6 +404,11 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return
if (autocomplete.visible) return
if (!store.prompt.input) return
const selectedModel = local.model.current()
if (!selectedModel) {
promptModelWarning()
return
}
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
@@ -423,8 +445,8 @@ export function Prompt(props: PromptProps) {
body: {
agent: local.agent.current().name,
model: {
providerID: local.model.current().providerID,
modelID: local.model.current().modelID,
providerID: selectedModel.providerID,
modelID: selectedModel.modelID,
},
command: inputText,
},
@@ -447,7 +469,7 @@ export function Prompt(props: PromptProps) {
command: command.slice(1),
arguments: args.join(" "),
agent: local.agent.current().name,
model: `${local.model.current().providerID}/${local.model.current().modelID}`,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID,
},
})
@@ -457,10 +479,10 @@ export function Prompt(props: PromptProps) {
id: sessionID,
},
body: {
...local.model.current(),
...selectedModel,
messageID,
agent: local.agent.current().name,
model: local.model.current(),
model: selectedModel,
parts: [
{
id: Identifier.ascending("part"),
@@ -496,6 +518,40 @@ export function Prompt(props: PromptProps) {
}
const exit = useExit()
function pasteText(text: string, virtualText: string) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + virtualText.length
input.insertText(virtualText + " ")
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId: pasteStyleId,
typeId: promptPartTypeId,
})
setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push({
type: "text" as const,
text,
source: {
text: {
start: extmarkStart,
end: extmarkEnd,
value: virtualText,
},
},
})
draft.extmarkToPartIndex.set(extmarkId, partIndex)
}),
)
}
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
@@ -551,12 +607,16 @@ export function Prompt(props: PromptProps) {
frames: createFrames({
color,
style: "blocks",
inactiveFactor: 0.25,
inactiveFactor: 0.6,
// enableFading: false,
minAlpha: 0.3,
}),
color: createColors({
color,
style: "blocks",
inactiveFactor: 0.25,
inactiveFactor: 0.6,
// enableFading: false,
minAlpha: 0.3,
}),
}
})
@@ -602,11 +662,7 @@ export function Prompt(props: PromptProps) {
flexGrow={1}
>
<textarea
placeholder={
props.showPlaceholder
? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
: undefined
}
placeholder={props.sessionID ? undefined : "Build anything..."}
textColor={theme.text}
focusedTextColor={theme.text}
minHeight={1}
@@ -705,25 +761,36 @@ export function Prompt(props: PromptProps) {
// trim ' from the beginning and end of the pasted content. just
// ' and nothing else
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
console.log(pastedContent, filepath)
try {
const file = Bun.file(filepath)
if (file.type.startsWith("image/")) {
event.preventDefault()
const content = await file
.arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(console.error)
if (content) {
await pasteImage({
filename: file.name,
mime: file.type,
content,
})
return
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const file = Bun.file(filepath)
// Handle SVG as raw text content, not as base64 image
if (file.type === "image/svg+xml") {
event.preventDefault()
const content = await file.text().catch(() => {})
if (content) {
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
return
}
}
}
} catch {}
if (file.type.startsWith("image/")) {
event.preventDefault()
const content = await file
.arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteImage({
filename: file.name,
mime: file.type,
content,
})
return
}
}
} catch {}
}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
@@ -731,45 +798,16 @@ export function Prompt(props: PromptProps) {
!sync.data.config.experimental?.disable_paste_summary
) {
event.preventDefault()
const currentOffset = input.visualCursor.offset
const virtualText = `[Pasted ~${lineCount} lines]`
const textToInsert = virtualText + " "
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + virtualText.length
input.insertText(textToInsert)
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId: pasteStyleId,
typeId: promptPartTypeId,
})
const part = {
type: "text" as const,
text: pastedContent,
source: {
text: {
start: extmarkStart,
end: extmarkEnd,
value: virtualText,
},
},
}
setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push(part)
draft.extmarkToPartIndex.set(extmarkId, partIndex)
}),
)
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
}}
ref={(r: TextareaRenderable) => (input = r)}
ref={(r: TextareaRenderable) => {
input = r
setTimeout(() => {
input.cursorColor = highlight()
}, 0)
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={highlight()}
@@ -796,7 +834,8 @@ export function Prompt(props: PromptProps) {
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "╹",
// when the background is transparent, don't draw the vertical line
vertical: theme.background.a != 0 ? "╹" : " ",
}}
>
<box
@@ -825,7 +864,8 @@ export function Prompt(props: PromptProps) {
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
{/* @ts-ignore // SpinnerOptions doesn't support marginLeft */}
<spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
@@ -837,7 +877,7 @@ export function Prompt(props: PromptProps) {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini 3 way too hot right now"
return "gemini is way too hot right now"
if (r.message.length > 50) return r.message.slice(0, 50) + "..."
return r.message
})
@@ -0,0 +1,12 @@
import { createMemo } from "solid-js"
import { useSync } from "./sync"
import { Global } from "@/global"
export function useDirectory() {
const sync = useSync()
return createMemo(() => {
const result = process.cwd().replace(Global.Path.home, "~")
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result
})
}
@@ -1,6 +1,6 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { FormatError } from "@/cli/error"
import { FormatError, FormatUnknownError } from "@/cli/error"
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
@@ -10,8 +10,10 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
renderer.destroy()
await input.onExit?.()
if (reason) {
const formatted = FormatError(reason) ?? JSON.stringify(reason)
process.stderr.write(formatted + "\n")
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
}
}
process.exit(0)
}
@@ -114,18 +114,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
providerID: string
modelID: string
}[]
favorite: {
providerID: string
modelID: string
}[]
}>({
ready: false,
model: {},
recent: [],
favorite: [],
})
const file = Bun.file(path.join(Global.Path.state, "model.json"))
function save() {
Bun.write(
file,
JSON.stringify({
recent: modelStore.recent,
favorite: modelStore.favorite,
}),
)
}
file
.json()
.then((x) => {
setModelStore("recent", x.recent)
if (Array.isArray(x.recent)) setModelStore("recent", x.recent)
if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite)
})
.catch(() => {})
.finally(() => {
@@ -159,8 +175,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return item
}
}
const provider = sync.data.provider[0]
const model = sync.data.provider_default[provider.id] ?? Object.values(provider.models)[0].id
if (!provider) return undefined
const defaultModel = sync.data.provider_default[provider.id]
const firstModel = Object.values(provider.models)[0]
const model = defaultModel ?? firstModel?.id
if (!model) return undefined
return {
providerID: provider.id,
modelID: model,
@@ -169,11 +190,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const currentModel = createMemo(() => {
const a = agent.current()
return getFirstValidModel(
() => modelStore.model[a.name],
() => a.model,
fallbackModel,
)!
return (
getFirstValidModel(
() => modelStore.model[a.name],
() => a.model,
fallbackModel,
) ?? undefined
)
})
return {
@@ -184,13 +207,22 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
recent() {
return modelStore.recent
},
favorite() {
return modelStore.favorite
},
parsed: createMemo(() => {
const value = currentModel()
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
const model = provider.models[value.modelID]
if (!value) {
return {
provider: "Connect a provider",
model: "No provider selected",
}
}
const provider = sync.data.provider.find((x) => x.id === value.providerID)
const info = provider?.models[value.modelID]
return {
provider: provider.name ?? value.providerID,
model: model.name ?? value.modelID,
provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID,
}
}),
cycle(direction: 1 | -1) {
@@ -206,6 +238,36 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!val) return
setModelStore("model", agent.current().name, { ...val })
},
cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
if (!favorites.length) {
toast.show({
variant: "info",
message: "Add a favorite model to use this shortcut",
duration: 3000,
})
return
}
const current = currentModel()
let index = -1
if (current) {
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
}
if (index === -1) {
index = direction === 1 ? 0 : favorites.length - 1
} else {
index += direction
if (index < 0) index = favorites.length - 1
if (index >= favorites.length) index = 0
}
const next = favorites[index]
if (!next) return
setModelStore("model", agent.current().name, { ...next })
const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 10) uniq.pop()
setModelStore("recent", uniq)
save()
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) {
@@ -219,17 +281,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setModelStore("model", agent.current().name, model)
if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
if (uniq.length > 10) uniq.pop()
setModelStore("recent", uniq)
Bun.write(
file,
JSON.stringify({
recent: modelStore.recent,
}),
)
save()
}
})
},
toggleFavorite(model: { providerID: string; modelID: string }) {
batch(() => {
if (!isModelValid(model)) {
toast.show({
message: `Model ${model.providerID}/${model.modelID} is not valid`,
variant: "warning",
duration: 3000,
})
return
}
const exists = modelStore.favorite.some(
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
)
const next = exists
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...modelStore.favorite]
setModelStore("favorite", next)
save()
})
},
}
})
@@ -1,7 +1,8 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import { iife } from "@/util/iife"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
@@ -16,43 +17,49 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
sdk.event.subscribe().then(async (events) => {
let queue: Event[] = []
let timer: Timer | undefined
let last = 0
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
}
onMount(async () => {
while (true) {
if (abort.signal.aborted) break
const events = await sdk.event.subscribe({
signal: abort.signal,
})
}
let queue: Event[] = []
let timer: Timer | undefined
let last = 0
for await (const event of events.stream) {
queue.push(event)
const elapsed = Date.now() - last
if (timer) continue
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
continue
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
}
})
}
flush()
}
// Flush any remaining events
if (timer) clearTimeout(timer)
if (queue.length > 0) {
flush()
for await (const event of events.stream) {
queue.push(event)
const elapsed = Date.now() - last
if (timer) continue
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
continue
}
flush()
}
// Flush any remaining events
if (timer) clearTimeout(timer)
if (queue.length > 0) {
flush()
}
}
})
@@ -14,6 +14,7 @@ import type {
SessionStatus,
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
@@ -22,6 +23,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -59,6 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[key: string]: McpStatus
}
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
}>({
provider_next: {
all: [],
@@ -82,6 +85,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
lsp: [],
mcp: {},
formatter: [],
vcs: undefined,
})
const sdk = useSDK()
@@ -238,6 +242,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
break
}
case "vcs.branch.updated": {
setStore("vcs", { branch: event.properties.branch })
break
}
}
})
@@ -276,11 +285,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
]).then(() => {
setStore("status", "complete")
})
})
.catch(async (e) => {
Log.Default.error("tui bootstrap failed", {
error: e instanceof Error ? e.message : String(e),
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
await exit(e)
})
}
@@ -8,6 +8,8 @@ import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useArgs } from "../context/args"
import { Global } from "@/global"
import { useDirectory } from "../context/directory"
// TODO: what is the best way to do this?
let once = false
@@ -15,6 +17,7 @@ let once = false
export function Home() {
const sync = useSync()
const { theme } = useTheme()
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
const mcpError = createMemo(() => {
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
})
@@ -47,31 +50,36 @@ export function Home() {
once = true
}
})
const directory = useDirectory()
return (
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
<Logo />
<box width={39}>
<HelpRow keybind="command_list">Commands</HelpRow>
<HelpRow keybind="session_list">List sessions</HelpRow>
<HelpRow keybind="model_list">Switch model</HelpRow>
<HelpRow keybind="agent_cycle">Switch agent</HelpRow>
<>
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
<Logo />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
<Prompt ref={(r) => (prompt = r)} hint={Hint} />
</box>
<Toast />
</box>
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
<Prompt ref={(r) => (prompt = r)} hint={Hint} />
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={1} flexDirection="row" flexShrink={0}>
<Show when={mcp()}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}> </span>
</Match>
</Switch>
{Object.keys(sync.data.mcp).length} MCP
</text>
<text fg={theme.textMuted}>/status</text>
</Show>
</box>
</box>
<Toast />
</box>
)
}
function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
const keybind = useKeybind()
const { theme } = useTheme()
return (
<box flexDirection="row" justifyContent="space-between" width="100%">
<text fg={theme.text}>{props.children}</text>
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
</box>
</>
)
}
@@ -0,0 +1,37 @@
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTheme } from "../../context/theme"
import { useSync } from "../../context/sync"
import { useDirectory } from "../../context/directory"
export function Footer() {
const { theme } = useTheme()
const sync = useSync()
const mcp = createMemo(() => Object.keys(sync.data.mcp))
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
const lsp = createMemo(() => Object.keys(sync.data.lsp))
const directory = useDirectory()
return (
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={2} flexDirection="row" flexShrink={0}>
<text fg={theme.text}>
<span style={{ fg: theme.success }}></span> {lsp().length} LSP
</text>
<Show when={mcp().length}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}> </span>
</Match>
</Switch>
{mcp().length} MCP
</text>
</Show>
<text fg={theme.textMuted}>/status</text>
</box>
</box>
)
}
@@ -3,15 +3,16 @@ import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { SplitBorder, EmptyBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
import { useDirectory } from "../../context/directory"
import { useKeybind } from "../../context/keybind"
const Title = (props: { session: Accessor<Session> }) => {
const { theme } = useTheme()
return (
<text fg={theme.text}>
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{props.session().title}</span>
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
</text>
)
}
@@ -53,43 +54,71 @@ export function Header() {
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
let result = total.toLocaleString()
if (model?.limit.context) {
result += "/" + Math.round((total / model.limit.context) * 100) + "%"
result += " " + Math.round((total / model.limit.context) * 100) + "%"
}
return result
})
const { theme } = useTheme()
const keybind = useKeybind()
return (
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={theme.backgroundElement} flexShrink={0}>
<Show
when={shareEnabled()}
fallback={
<box flexDirection="row" justifyContent="space-between" gap={1}>
<Title session={session} />
<ContextInfo context={context} cost={cost} />
</box>
}
<box flexShrink={0}>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
{...SplitBorder}
border={["left"]}
borderColor={theme.border}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
>
<Title session={session} />
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
<ContextInfo context={context} cost={cost} />
</box>
</Show>
<Switch>
<Match when={session()?.parentID}>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
<box flexGrow={1} flexShrink={1} />
<ContextInfo context={context} cost={cost} />
</box>
</Match>
<Match when={!shareEnabled()}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<Title session={session} />
<ContextInfo context={context} cost={cost} />
</box>
</Match>
<Match when={true}>
<Title session={session} />
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
<ContextInfo context={context} cost={cost} />
</box>
</Match>
</Switch>
</box>
</box>
)
}
@@ -62,6 +62,7 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
addDefaultParsers(parsers.parsers)
@@ -80,6 +81,9 @@ const context = createContext<{
conceal: () => boolean
showThinking: () => boolean
showTimestamps: () => boolean
showDetails: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
}>()
function use() {
@@ -109,11 +113,18 @@ export function Session() {
const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
const [conceal, setConceal] = createSignal(true)
const [showThinking, setShowThinking] = createSignal(true)
const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true))
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide()))
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebar() === "show") return true
if (sidebar() === "auto" && wide()) return true
return false
})
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => {
@@ -260,13 +271,22 @@ export function Session() {
keybind: "session_compact",
category: "Session",
onSelect: (dialog) => {
const selectedModel = local.model.current()
if (!selectedModel) {
toast.show({
variant: "warning",
message: "Connect a provider to summarize this session",
duration: 3000,
})
return
}
sdk.client.session.summarize({
path: {
id: route.sessionID,
},
body: {
modelID: local.model.current().modelID,
providerID: local.model.current().providerID,
modelID: selectedModel.modelID,
providerID: selectedModel.providerID,
},
})
dialog.clear()
@@ -319,7 +339,9 @@ export function Session() {
value: "session.undo",
keybind: "messages_undo",
category: "Session",
onSelect: (dialog) => {
onSelect: async (dialog) => {
const status = sync.data.session_status[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ path: { id: route.sessionID } }).catch(() => {})
const revert = session().revert?.messageID
const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
if (!message) return
@@ -421,11 +443,35 @@ export function Session() {
},
},
{
title: "Toggle thinking blocks",
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
category: "Session",
onSelect: (dialog) => {
setShowThinking((prev) => !prev)
setShowThinking((prev) => {
const next = !prev
kv.set("thinking_visibility", next)
return next
})
dialog.clear()
},
},
{
title: "Toggle diff wrapping",
value: "session.toggle.diffwrap",
category: "Session",
onSelect: (dialog) => {
setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
dialog.clear()
},
},
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
category: "Session",
onSelect: (dialog) => {
const newValue = !showDetails()
setShowDetails(newValue)
kv.set("tool_details_visibility", newValue)
dialog.clear()
},
},
@@ -730,33 +776,14 @@ export function Session() {
conceal,
showThinking,
showTimestamps,
showDetails,
diffWrapMode,
sync,
}}
>
<box flexDirection="row" paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={2}>
<box flexGrow={1} gap={1}>
<box flexDirection="row">
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<Show when={session().parentID}>
<box
backgroundColor={theme.backgroundPanel}
justifyContent="space-between"
flexDirection="row"
paddingTop={1}
paddingBottom={1}
flexShrink={0}
paddingLeft={2}
paddingRight={2}
>
<text fg={theme.text}>
Previous <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
<text fg={theme.text}>
<b>Viewing subagent session</b>
</text>
<text fg={theme.text}>
<span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span> Next
</text>
</box>
</Show>
<Show when={!sidebarVisible()}>
<Header />
</Show>
@@ -881,6 +908,9 @@ export function Session() {
sessionID={route.sessionID}
/>
</box>
<Show when={!sidebarVisible()}>
<Footer />
</Show>
</Show>
<Toast />
</box>
@@ -1121,9 +1151,21 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
const { theme } = useTheme()
const { showDetails } = use()
const sync = useSync()
const [margin, setMargin] = createSignal(0)
const component = createMemo(() => {
// Hide tool if showDetails is false and tool completed successfully
// But always show if there's an error or permission is required
const shouldHide =
!showDetails() &&
props.part.state.status === "completed" &&
!sync.data.permission[props.message.sessionID]?.some((x) => x.callID === props.part.callID)
if (shouldHide) {
return undefined
}
const render = ToolRegistry.render(props.part.tool) ?? GenericTool
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
@@ -1194,15 +1236,15 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<box gap={1}>
<text fg={theme.text}>Permission required to run this tool:</text>
<box flexDirection="row" gap={2}>
<text>
<text fg={theme.text}>
<b>enter</b>
<span style={{ fg: theme.textMuted }}> accept</span>
</text>
<text>
<text fg={theme.text}>
<b>a</b>
<span style={{ fg: theme.textMuted }}> accept always</span>
</text>
<text>
<text fg={theme.text}>
<b>d</b>
<span style={{ fg: theme.textMuted }}> deny</span>
</text>
@@ -1307,21 +1349,9 @@ ToolRegistry.register<typeof WriteTool>({
container: "block",
render(props) {
const { theme, syntax } = useTheme()
const lines = createMemo(
() => (typeof props.input.content === "string" ? props.input.content.split("\n") : []),
[] as string[],
)
const code = createMemo(() => {
if (!props.input.content) return ""
const text = props.input.content
return text
})
const numbers = createMemo(() => {
const pad = lines().length.toString().length
return lines()
.map((_, index) => index + 1)
.map((x) => x.toString().padStart(pad, " "))
return props.input.content
})
const diagnostics = createMemo(() => props.metadata.diagnostics?.[props.input.filePath ?? ""] ?? [])
@@ -1331,14 +1361,15 @@ ToolRegistry.register<typeof WriteTool>({
<ToolTitle icon="←" fallback="Preparing write..." when={props.input.filePath}>
Wrote {props.input.filePath}
</ToolTitle>
<box flexDirection="row">
<box flexShrink={0}>
<For each={numbers()}>{(value) => <text style={{ fg: theme.textMuted }}>{value}</text>}</For>
</box>
<box paddingLeft={1} flexGrow={1}>
<code fg={theme.text} filetype={filetype(props.input.filePath!)} syntaxStyle={syntax()} content={code()} />
</box>
</box>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={theme.text}
filetype={filetype(props.input.filePath!)}
syntaxStyle={syntax()}
content={code()}
/>
</line_number>
<Show when={diagnostics().length}>
<For each={diagnostics()}>
{(diagnostic) => (
@@ -1410,15 +1441,15 @@ ToolRegistry.register<typeof TaskTool>({
return (
<>
<ToolTitle icon="%" fallback="Delegating..." when={props.input.subagent_type ?? props.input.description}>
Task [{props.input.subagent_type ?? "unknown"}] {props.input.description}
<ToolTitle icon="" fallback="Delegating..." when={props.input.subagent_type ?? props.input.description}>
{Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
</ToolTitle>
<Show when={props.metadata.summary?.length}>
<box>
<For each={props.metadata.summary ?? []}>
{(task) => (
<text style={{ fg: theme.textMuted }}>
{task.tool} {task.state.status === "completed" ? task.state.title : ""}
{Locale.titlecase(task.tool)} {task.state.status === "completed" ? task.state.title : ""}
</text>
)}
</For>
@@ -1445,6 +1476,34 @@ ToolRegistry.register<typeof WebFetchTool>({
},
})
ToolRegistry.register({
name: "codesearch",
container: "inline",
render(props: ToolProps<any>) {
const input = props.input as any
const metadata = props.metadata as any
return (
<ToolTitle icon="◇" fallback="Searching code..." when={input.query}>
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
</ToolTitle>
)
},
})
ToolRegistry.register({
name: "websearch",
container: "inline",
render(props: ToolProps<any>) {
const input = props.input as any
const metadata = props.metadata as any
return (
<ToolTitle icon="◈" fallback="Searching web..." when={input.query}>
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
</ToolTitle>
)
},
})
ToolRegistry.register<typeof EditTool>({
name: "edit",
container: "block",
@@ -1452,79 +1511,17 @@ ToolRegistry.register<typeof EditTool>({
const ctx = use()
const { theme, syntax } = useTheme()
const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
const diff = createMemo(() => {
const diff = props.metadata.diff ?? props.permission["diff"]
if (!diff) return null
try {
const patches = parsePatch(diff)
if (patches.length === 0) return null
const patch = patches[0]
const oldLines: string[] = []
const newLines: string[] = []
for (const hunk of patch.hunks) {
let i = 0
while (i < hunk.lines.length) {
const line = hunk.lines[i]
if (line.startsWith("-")) {
const removedLines: string[] = []
while (i < hunk.lines.length && hunk.lines[i].startsWith("-")) {
removedLines.push("- " + hunk.lines[i].slice(1))
i++
}
const addedLines: string[] = []
while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
addedLines.push("+ " + hunk.lines[i].slice(1))
i++
}
const maxLen = Math.max(removedLines.length, addedLines.length)
for (let j = 0; j < maxLen; j++) {
oldLines.push(removedLines[j] ?? "")
newLines.push(addedLines[j] ?? "")
}
} else if (line.startsWith("+")) {
const addedLines: string[] = []
while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
addedLines.push("+ " + hunk.lines[i].slice(1))
i++
}
for (const added of addedLines) {
oldLines.push("")
newLines.push(added)
}
} else {
oldLines.push(" " + line.slice(1))
newLines.push(" " + line.slice(1))
i++
}
}
}
return {
oldContent: oldLines.join("\n"),
newContent: newLines.join("\n"),
}
} catch (error) {
return null
}
})
const code = createMemo(() => {
if (!props.metadata.diff) return ""
const text = props.metadata.diff.split("\n").slice(5).join("\n")
return text.trim()
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
// Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified"
})
const ft = createMemo(() => filetype(props.input.filePath))
const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"])
const diagnostics = createMemo(() => {
const arr = props.metadata.diagnostics?.[props.input.filePath ?? ""] ?? []
return arr.filter((x) => x.severity === 1).slice(0, 3)
@@ -1538,26 +1535,28 @@ ToolRegistry.register<typeof EditTool>({
replaceAll: props.input.replaceAll,
})}
</ToolTitle>
<Switch>
<Match when={props.permission["diff"]}>
<text fg={theme.text}>{props.permission["diff"]?.trim()}</text>
</Match>
<Match when={diff() && style() === "split"}>
<box paddingLeft={1} flexDirection="row" gap={2}>
<box flexGrow={1} flexBasis={0}>
<code fg={theme.text} filetype={ft()} syntaxStyle={syntax()} content={diff()!.oldContent} />
</box>
<box flexGrow={1} flexBasis={0}>
<code fg={theme.text} filetype={ft()} syntaxStyle={syntax()} content={diff()!.newContent} />
</box>
</box>
</Match>
<Match when={code()}>
<box paddingLeft={1}>
<code fg={theme.text} filetype={ft()} syntaxStyle={syntax()} content={code()} />
</box>
</Match>
</Switch>
<Show when={diffContent()}>
<box paddingLeft={1}>
<diff
diff={diffContent()}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
</Show>
<Show when={diagnostics().length}>
<box>
<For each={diagnostics()}>
@@ -1,9 +1,14 @@
import { useSync } from "@tui/context/sync"
import { createMemo, For, Show, Switch, Match, createSignal } from "solid-js"
import { createMemo, For, Show, Switch, Match } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
import path from "path"
import type { AssistantMessage } from "@opencode-ai/sdk"
import { Global } from "@/global"
import { Installation } from "@/installation"
import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
export function Sidebar(props: { sessionID: string }) {
const sync = useSync()
@@ -13,10 +18,12 @@ export function Sidebar(props: { sessionID: string }) {
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const [mcpExpanded, setMcpExpanded] = createSignal(true)
const [diffExpanded, setDiffExpanded] = createSignal(true)
const [todoExpanded, setTodoExpanded] = createSignal(true)
const [lspExpanded, setLspExpanded] = createSignal(true)
const [expanded, setExpanded] = createStore({
mcp: true,
diff: true,
todo: true,
lsp: true,
})
// Sort MCP servers alphabetically for consistent display order
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
@@ -41,87 +48,104 @@ export function Sidebar(props: { sessionID: string }) {
}
})
const keybind = useKeybind()
const directory = useDirectory()
const hasProviders = createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
return (
<Show when={session()}>
<scrollbox width={40}>
<box flexShrink={0} gap={1} paddingRight={1}>
<box>
<text fg={theme.text}>
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
<text fg={theme.textMuted}>{session().share!.url}</text>
</Show>
</box>
<box>
<text fg={theme.text}>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={mcpEntries().length > 0}>
<box
backgroundColor={theme.backgroundPanel}
width={42}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
>
<scrollbox flexGrow={1}>
<box flexShrink={0} gap={1} paddingRight={1}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => mcpEntries().length > 2 && setMcpExpanded(!mcpExpanded())}
>
<Show when={mcpEntries().length > 2}>
<text fg={theme.text}>{mcpExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>MCP</b>
</text>
</box>
<Show when={mcpEntries().length <= 2 || mcpExpanded()}>
<For each={mcpEntries()}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
}[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
<text fg={theme.text}>
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
<text fg={theme.textMuted}>{session().share!.url}</text>
</Show>
</box>
</Show>
<Show when={sync.data.lsp.length > 0}>
<box>
<text fg={theme.text}>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={mcpEntries().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
>
<Show when={mcpEntries().length > 2}>
<text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>MCP</b>
</text>
</box>
<Show when={mcpEntries().length <= 2 || expanded.mcp}>
<For each={mcpEntries()}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
}[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</Show>
</box>
</Show>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => sync.data.lsp.length > 2 && setLspExpanded(!lspExpanded())}
onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
>
<Show when={sync.data.lsp.length > 2}>
<text fg={theme.text}>{lspExpanded() ? "▼" : "▶"}</text>
<text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>LSP</b>
</text>
</box>
<Show when={sync.data.lsp.length <= 2 || lspExpanded()}>
<Show when={sync.data.lsp.length <= 2 || expanded.lsp}>
<Show when={sync.data.lsp.length === 0}>
<text fg={theme.textMuted}>LSPs will activate as files are read</text>
</Show>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
@@ -144,78 +168,115 @@ export function Sidebar(props: { sessionID: string }) {
</For>
</Show>
</box>
</Show>
<Show when={todo().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => todo().length > 2 && setTodoExpanded(!todoExpanded())}
>
<Show when={todo().length > 2}>
<text fg={theme.text}>{todoExpanded() ? "▼" : "▶"}</text>
<Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
>
<Show when={todo().length > 2}>
<text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Todo</b>
</text>
</box>
<Show when={todo().length <= 2 || expanded.todo}>
<For each={todo()}>
{(todo) => (
<text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}
</For>
</Show>
<text fg={theme.text}>
<b>Todo</b>
</text>
</box>
<Show when={todo().length <= 2 || todoExpanded()}>
<For each={todo()}>
{(todo) => (
<text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}
</For>
</Show>
</box>
</Show>
<Show when={diff().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => diff().length > 2 && setDiffExpanded(!diffExpanded())}
>
<Show when={diff().length > 2}>
<text fg={theme.text}>{diffExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
</box>
<Show when={diff().length <= 2 || diffExpanded()}>
<For each={diff() || []}>
{(item) => {
const file = createMemo(() => {
const splits = item.file.split(path.sep).filter(Boolean)
const last = splits.at(-1)!
const rest = splits.slice(0, -1).join(path.sep)
if (!rest) return last
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
})
return (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} wrapMode="char">
{file()}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme.diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={theme.diffRemoved}>-{item.deletions}</text>
</Show>
</Show>
<Show when={diff().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
>
<Show when={diff().length > 2}>
<text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
</box>
<Show when={diff().length <= 2 || expanded.diff}>
<For each={diff() || []}>
{(item) => {
const file = createMemo(() => {
const splits = item.file.split(path.sep).filter(Boolean)
const last = splits.at(-1)!
const rest = splits.slice(0, -1).join(path.sep)
if (!rest) return last
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
})
return (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} wrapMode="char">
{file()}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme.diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={theme.diffRemoved}>-{item.deletions}</text>
</Show>
</box>
</box>
</box>
)
}}
</For>
</Show>
)
}}
</For>
</Show>
</box>
</Show>
</box>
</scrollbox>
<box flexShrink={0} gap={1}>
<Show when={!hasProviders()}>
<box
backgroundColor={theme.backgroundElement}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
gap={1}
>
<text flexShrink={0}></text>
<box flexGrow={1} gap={1}>
<text>
<b>Getting started</b>
</text>
<text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
<text fg={theme.textMuted}>
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
</text>
<box flexDirection="row" gap={1} justifyContent="space-between">
<text>Connect provider</text>
<text fg={theme.textMuted}>/connect</text>
</box>
</box>
</box>
</Show>
<text fg={theme.textMuted}>{directory()}</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.success }}></span> <b>Open</b>
<span style={{ fg: theme.text }}>
<b>Code</b>
</span>{" "}
<span>{Installation.VERSION}</span>
</text>
</box>
</scrollbox>
</box>
</Show>
)
}
@@ -55,9 +55,6 @@ export function DialogPrompt(props: DialogPromptProps) {
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>cancel</span>
</text>
</box>
</box>
)
@@ -13,6 +13,7 @@ import { Locale } from "@/util/locale"
export interface DialogSelectProps<T> {
title: string
placeholder?: string
options: DialogSelectOption<T>[]
ref?: (ref: DialogSelectRef<T>) => void
onMove?: (option: DialogSelectOption<T>) => void
@@ -21,6 +22,7 @@ export interface DialogSelectProps<T> {
keybind?: {
keybind: Keybind.Info
title: string
disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void
}[]
current?: T
@@ -150,6 +152,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
for (const item of props.keybind ?? []) {
if (item.disabled) continue
if (Keybind.match(item.keybind, keybind.parse(evt))) {
const s = selected()
if (s) {
@@ -171,8 +174,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled) ?? [])
return (
<box gap={1}>
<box gap={1} paddingBottom={1}>
<box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
@@ -195,7 +200,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
input = r
setTimeout(() => input.focus(), 1)
}}
placeholder="Enter search term"
placeholder={props.placeholder ?? "Search"}
/>
</box>
</box>
@@ -253,18 +258,20 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)}
</For>
</scrollbox>
<box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}>
<For each={props.keybind ?? []}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
<For each={keybinds()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
</Show>
</box>
)
}
+52 -39
View File
@@ -8,6 +8,8 @@ interface AdvancedGradientOptions {
defaultColor?: ColorInput
direction?: "forward" | "backward" | "bidirectional"
holdFrames?: { start?: number; end?: number }
enableFading?: boolean
minAlpha?: number
}
interface ScannerState {
@@ -137,13 +139,16 @@ function calculateColorIndex(
}
function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerator {
const { colors, defaultColor } = options
const { colors, defaultColor, enableFading = true, minAlpha = 0 } = options
// Use the provided defaultColor if it's an RGBA instance, otherwise convert/default
// We use RGBA.fromHex for the fallback to ensure we have an RGBA object.
// Note: If defaultColor is a string, we convert it once here.
const defaultRgba = defaultColor instanceof RGBA ? defaultColor : RGBA.fromHex((defaultColor as string) || "#000000")
// Store the base alpha from the inactive factor
const baseInactiveAlpha = defaultRgba.a
let cachedFrameIndex = -1
let cachedState: ScannerState | null = null
@@ -160,22 +165,22 @@ function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerato
// Calculate global fade for inactive dots during hold or movement
const { isHolding, holdProgress, holdTotal, movementProgress, movementTotal } = state
let alpha = 1.0
if (isHolding && holdTotal > 0) {
// Fade out linearly
const progress = Math.min(holdProgress / holdTotal, 1)
alpha = Math.max(0, 1 - progress)
} else if (!isHolding && movementTotal > 0) {
// Fade in linearly during movement
const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1)
alpha = progress
let fadeFactor = 1.0
if (enableFading) {
if (isHolding && holdTotal > 0) {
// Fade out linearly to minAlpha
const progress = Math.min(holdProgress / holdTotal, 1)
fadeFactor = Math.max(minAlpha, 1 - progress * (1 - minAlpha))
} else if (!isHolding && movementTotal > 0) {
// Fade in linearly from minAlpha during movement
const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1)
fadeFactor = minAlpha + progress * (1 - minAlpha)
}
}
// Mutate the alpha of the default RGBA object
// This assumes single-threaded, synchronous rendering per frame
// where we can modify the state for the current frame.
// Since this is run for every char in the frame, setting it repeatedly to the same value is fine.
defaultRgba.a = alpha
// Combine base inactive alpha with the fade factor
// This ensures inactiveFactor is respected while still allowing fading animation
defaultRgba.a = baseInactiveAlpha * fadeFactor
if (index === -1) {
return defaultRgba
@@ -186,10 +191,10 @@ function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerato
}
/**
* Derives a gradient of tail colors from a single bright color
* Derives a gradient of tail colors from a single bright color using alpha falloff
* @param brightColor The brightest color (center/head of the scanner)
* @param steps Number of gradient steps (default: 6)
* @returns Array of RGBA colors from brightest to darkest
* @returns Array of RGBA colors with alpha-based trail fade (background-independent)
*/
export function deriveTrailColors(brightColor: ColorInput, steps: number = 6): RGBA[] {
const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
@@ -197,45 +202,45 @@ export function deriveTrailColors(brightColor: ColorInput, steps: number = 6): R
const colors: RGBA[] = []
for (let i = 0; i < steps; i++) {
// Progressive darkening:
// i=0: 100% brightness (original color)
// i=1: add slight bloom/glare (lighten)
// i=2+: progressively darken
let factor: number
// Alpha-based falloff with optional bloom effect
let alpha: number
let brightnessFactor: number
if (i === 0) {
factor = 1.0 // Original brightness
// Lead position: full brightness and opacity
alpha = 1.0
brightnessFactor = 1.0
} else if (i === 1) {
factor = 1.2 // Slight bloom/glare effect
// Slight bloom/glare effect: brighten color but reduce opacity slightly
alpha = 0.9
brightnessFactor = 1.15
} else {
// Exponential decay for natural-looking trail fade
factor = Math.pow(0.6, i - 1)
// Exponential alpha decay for natural-looking trail fade
alpha = Math.pow(0.65, i - 1)
brightnessFactor = 1.0
}
const r = Math.min(1.0, baseRgba.r * factor)
const g = Math.min(1.0, baseRgba.g * factor)
const b = Math.min(1.0, baseRgba.b * factor)
const r = Math.min(1.0, baseRgba.r * brightnessFactor)
const g = Math.min(1.0, baseRgba.g * brightnessFactor)
const b = Math.min(1.0, baseRgba.b * brightnessFactor)
colors.push(RGBA.fromValues(r, g, b, 1.0))
colors.push(RGBA.fromValues(r, g, b, alpha))
}
return colors
}
/**
* Derives the inactive/default color from a bright color
* Derives the inactive/default color from a bright color using alpha
* @param brightColor The brightest color (center/head of the scanner)
* @param factor Brightness factor for inactive color (default: 0.2)
* @returns A much darker version suitable for inactive dots
* @param factor Alpha factor for inactive color (default: 0.2, range: 0-1)
* @returns The same color with reduced alpha for background-independent dimming
*/
export function deriveInactiveColor(brightColor: ColorInput, factor: number = 0.2): RGBA {
const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
const r = baseRgba.r * factor
const g = baseRgba.g * factor
const b = baseRgba.b * factor
return RGBA.fromValues(r, g, b, 1.0)
// Use the full color brightness but adjust alpha for background-independent dimming
return RGBA.fromValues(baseRgba.r, baseRgba.g, baseRgba.b, factor)
}
export type KnightRiderStyle = "blocks" | "diamonds"
@@ -251,8 +256,12 @@ export interface KnightRiderOptions {
/** Number of trail steps when using single color (default: 6) */
trailSteps?: number
defaultColor?: ColorInput
/** Brightness factor for inactive color when using single color (default: 0.2) */
/** Alpha factor for inactive color when using single color (default: 0.2, range: 0-1) */
inactiveFactor?: number
/** Enable fading of inactive dots during hold and movement (default: true) */
enableFading?: boolean
/** Minimum alpha value when fading (default: 0, range: 0-1) */
minAlpha?: number
}
/**
@@ -289,6 +298,8 @@ export function createFrames(options: KnightRiderOptions = {}): string[] {
defaultColor,
direction: "bidirectional" as const,
holdFrames: { start: holdStart, end: holdEnd },
enableFading: options.enableFading,
minAlpha: options.minAlpha,
}
// Bidirectional cycle: Forward (width) + Hold End + Backward (width-1) + Hold Start
@@ -349,6 +360,8 @@ export function createColors(options: KnightRiderOptions = {}): ColorGenerator {
defaultColor,
direction: "bidirectional" as const,
holdFrames: { start: holdStart, end: holdEnd },
enableFading: options.enableFading,
minAlpha: options.minAlpha,
}
return createKnightRiderTrail(trailOptions)
+15
View File
@@ -38,3 +38,18 @@ export function FormatError(input: unknown) {
if (UI.CancelledError.isInstance(input)) return ""
}
export function FormatUnknownError(input: unknown): string {
if (input instanceof Error) {
return input.stack ?? `${input.name}: ${input.message}`
}
if (typeof input === "object" && input !== null) {
try {
const json = JSON.stringify(input, null, 2)
if (json && json !== "{}") return json
} catch {}
}
return String(input)
}
+20 -14
View File
@@ -1,15 +1,12 @@
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import { Bus } from "../bus"
import { Identifier } from "../id/id"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
export namespace Command {
export const Default = {
INIT: "init",
} as const
export const Event = {
Executed: Bus.event(
"command.executed",
@@ -36,10 +33,27 @@ export namespace Command {
})
export type Info = z.infer<typeof Info>
export const Default = {
INIT: "init",
REVIEW: "review",
} as const
const state = Instance.state(async () => {
const cfg = await Config.get()
const result: Record<string, Info> = {}
const result: Record<string, Info> = {
[Default.INIT]: {
name: Default.INIT,
description: "create/update AGENTS.md",
template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
},
[Default.REVIEW]: {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
template: PROMPT_REVIEW.replace("${path}", Instance.worktree),
subtask: true,
},
}
for (const [name, command] of Object.entries(cfg.command ?? {})) {
result[name] = {
@@ -52,14 +66,6 @@ export namespace Command {
}
}
if (result[Default.INIT] === undefined) {
result[Default.INIT] = {
name: Default.INIT,
description: "create/update AGENTS.md",
template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
}
}
return result
})
@@ -0,0 +1,73 @@
You are a code reviewer. Your job is to review code changes and provide actionable feedback.
---
Input: $ARGUMENTS
---
## Determining What to Review
Based on the input provided, determine which type of review to perform:
1. **No arguments (default)**: Review all uncommitted changes
- Run: `git diff` for unstaged changes
- Run: `git diff --cached` for staged changes
2. **Commit hash** (40-char SHA or short hash): Review that specific commit
- Run: `git show $ARGUMENTS`
3. **Branch name**: Compare current branch to the specified branch
- Run: `git diff $ARGUMENTS...HEAD`
4. **PR URL or number** (contains "github.com" or "pull" or looks like a PR number): Review the pull request
- Run: `gh pr view $ARGUMENTS` to get PR context
- Run: `gh pr diff $ARGUMENTS` to get the diff
Use best judgement when processing input.
---
## What to Look For
**Bugs** - Your primary focus.
- Logic errors, off-by-one mistakes, incorrect conditionals
- Edge cases: null/empty inputs, error conditions, race conditions
- Security issues: injection, auth bypass, data exposure
- Broken error handling that swallows failures
**Structure** - Does the code fit the codebase?
- Does it follow existing patterns and conventions?
- Are there established abstractions it should use but doesn't?
**Performance** - Only flag if obviously problematic.
- O(n²) on unbounded data, N+1 queries, blocking I/O on hot paths
## Before You Flag Something
Be certain. If you're going to call something a bug, you need to be confident it actually is one.
- Only review the changes - do not review pre-existing code that wasn't modified
- Don't flag something as a bug if you're unsure - investigate first
- Don't flag style preferences as issues
- Don't invent hypothetical problems - if an edge case matters, explain the realistic scenario where it breaks
- If you need more context to be sure, use the tools below to get it
## Tools
Use these to inform your review:
- **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit.
- **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong.
- **Exa Web Search** - Research best practices if you're unsure about a pattern.
If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue.
## Tone and Approach
1. If there is a bug, be direct and clear about why it is a bug.
2. You should clearly communicate severity of issues, do not claim issues are more severe than they actually are.
3. Critiques should clearly and explicitly communicate the scenarios, environments, or inputs that are necessary for the bug to arise. The comment should immediately indicate that the issue's severity depends on these factors.
4. Your tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer.
5. Write in a manner that allows reader to quickly understand issue without reading too closely.
6. AVOID flattery, do not give any comments that are not helpful to the reader. Avoid phrasing like "Great job ...", "Thanks for ...".
+9
View File
@@ -456,6 +456,10 @@ export namespace Config {
})
.optional()
.describe("Scroll acceleration settings"),
diff_style: z
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
})
export const Layout = z.enum(["auto", "stretch"]).meta({
@@ -523,6 +527,7 @@ export namespace Config {
plan: Agent.optional(),
build: Agent.optional(),
general: Agent.optional(),
explore: Agent.optional(),
})
.catchall(Agent)
.optional()
@@ -540,6 +545,10 @@ export namespace Config {
apiKey: z.string().optional(),
baseURL: z.string().optional(),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
setCacheKey: z
.boolean()
.optional()
.describe("Enable promptCacheKey for this provider (default false)"),
timeout: z
.union([
z
+10
View File
@@ -6,6 +6,7 @@ export namespace FileIgnore {
"bower_components",
".pnpm-store",
"vendor",
".npm",
"dist",
"build",
"out",
@@ -22,12 +23,21 @@ export namespace FileIgnore {
".output",
"desktop",
".sst",
".cache",
".webkit-cache",
"__pycache__",
".pytest_cache",
"mypy_cache",
".history",
".gradle",
])
const FILES = [
"**/*.swp",
"**/*.swo",
"**/*.pyc",
// OS
"**/.DS_Store",
"**/Thumbs.db",
+31 -19
View File
@@ -1,6 +1,5 @@
import z from "zod"
import { Bus } from "../bus"
import { Flag } from "../flag/flag"
import { Instance } from "../project/instance"
import { Log } from "../util/log"
import { FileIgnore } from "./ignore"
@@ -8,6 +7,7 @@ import { Config } from "../config/config"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import type ParcelWatcher from "@parcel/watcher"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
@@ -44,32 +44,44 @@ export namespace FileWatcher {
return {}
}
log.info("watcher backend", { platform: process.platform, backend })
const sub = await watcher().subscribe(
Instance.directory,
(err, evts) => {
if (err) return
for (const evt of evts) {
log.info("event", evt)
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
},
{
ignore: [...FileIgnore.PATTERNS, ...(cfg.watcher?.ignore ?? [])],
const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
}
const subs = []
const cfgIgnores = cfg.watcher?.ignore ?? []
subs.push(
await watcher().subscribe(Instance.directory, subscribe, {
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
backend,
},
}),
)
return { sub }
const vcsDir = Instance.project.vcsDir
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
subs.push(
await watcher().subscribe(vcsDir, subscribe, {
ignore: ["hooks", "info", "logs", "objects", "refs", "worktrees", "modules", "lfs"],
backend,
}),
)
}
return { subs }
},
async (state) => {
if (!state.sub) return
await state.sub?.unsubscribe()
if (!state.subs) return
await Promise.all(state.subs.map((sub) => sub?.unsubscribe()))
},
)
export function init() {
if (!Flag.OPENCODE_EXPERIMENTAL_WATCHER) return
state()
}
}
-1
View File
@@ -17,7 +17,6 @@ export namespace Flag {
// Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
export const OPENCODE_EXPERIMENTAL_WATCHER = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WATCHER")
export const OPENCODE_EXPERIMENTAL_EXA = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
@@ -246,3 +246,12 @@ export const htmlbeautifier: Info = {
return Bun.which("htmlbeautifier") !== null
},
}
export const dart: Info = {
name: "dart",
command: ["dart", "format", "$FILE"],
extensions: [".dart"],
async enabled() {
return Bun.which("dart") !== null
},
}
+1 -1
View File
@@ -30,7 +30,7 @@ await Promise.all([
fs.mkdir(Global.Path.bin, { recursive: true }),
])
const CACHE_VERSION = "11"
const CACHE_VERSION = "12"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()
+7 -1
View File
@@ -4,6 +4,7 @@ import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { iife } from "@/util/iife"
declare global {
const OPENCODE_VERSION: string
@@ -163,9 +164,14 @@ export namespace Installation {
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}`
export async function latest() {
const registry = await iife(async () => {
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})
const [major] = VERSION.split(".").map((x) => Number(x))
const channel = CHANNEL === "latest" ? `latest-${major}` : CHANNEL
return fetch(`https://registry.npmjs.org/opencode-ai/${channel}`)
return fetch(`${registry}/opencode-ai/${channel}`)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()

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