Compare commits

..

267 Commits

Author SHA1 Message Date
Aiden Cline
4af389a2a2 fix: use dynamic import for Plugin to avoid circular dependency 2026-01-07 16:16:23 -06:00
Aiden Cline
fbc60399a2 fix: rm dead code and add back permission hook 2026-01-07 15:05:39 -06:00
Aiden Cline
fe57d7bb38 tweak: title gen prompt & temp to avoid repetative 'Analyzing ...' titles 2026-01-07 12:56:41 -06:00
Ravi Kumar
68cf6b04a0 fix(tui): constrain autocomplete height to available screen space (#7181) 2026-01-07 19:24:44 +01:00
Github Action
9ffaf81fb3 Update Nix flake.lock and hashes 2026-01-07 18:07:10 +00:00
GitHub Action
50530b1ea7 chore: generate 2026-01-07 18:06:30 +00:00
Rodolfo Carvalho
a160eee499 fix(mcp): use correct authorization server URL for OAuth discovery (#7234) 2026-01-07 12:05:54 -06:00
Thanh Nguyen
d9aef1d73d fix(theme): add selectedListItemText to orng themes for button text visibility (#7169) 2026-01-07 12:05:46 -06:00
Matt Silverlock
4ba0b22b04 fix: config precedence now correctly allows local config to override remote (#7141) 2026-01-07 12:07:21 -05:00
Aiden Cline
662d2b205a docs: update brew formula stuff to recommend our tap 2026-01-07 11:06:54 -06:00
GitHub Action
75960ae00c chore: generate 2026-01-07 17:03:43 +00:00
Aiden Cline
528f198c39 tweak: update formula for anomalyco tap 2026-01-07 11:02:37 -06:00
opencode
184834da98 release: v1.1.6 2026-01-07 16:38:32 +00:00
Dax Raad
008a5c10cc oops 2026-01-07 11:35:11 -05:00
Daniel Polito
2d5b9a5cc6 Desktop: Fix Paste image on empty input (#7130) 2026-01-07 09:40:21 -06:00
Andrew Thal
fb3ca895d6 fix(ui): prevent iOS Safari auto-zoom on input focus (#7214) 2026-01-07 09:22:48 -06:00
Brendan Allan
d3d379fe2e desktop: bundleMediaFramework 2026-01-07 22:55:29 +08:00
GitHub Action
b41626049c chore: generate 2026-01-07 14:50:57 +00:00
Dax Raad
e59be27810 theme 2026-01-07 09:50:04 -05:00
opencode
1e2992244f release: v1.1.5 2026-01-07 14:37:04 +00:00
Dax Raad
fd22b26478 theme reload 2026-01-07 09:34:11 -05:00
Dax Raad
ea2ee46f45 clear palette cache 2026-01-07 08:49:35 -05:00
Adam
4e1b6b3417 fix(app): select first item when filtering 2026-01-07 07:15:06 -06:00
jwaterwater
2d52a461a0 fix: encode non-ASCII directory paths in HTTP headers (#7145)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-07 06:46:17 -06:00
shuv
9cce0cf4f4 feat: add Night Owl theme (desktop) (#7151)
Co-authored-by: shuv <shuv@shuv.dev>
2026-01-07 06:44:27 -06:00
Brendan Allan
a41c8508da desktop: go back to regular tauri cli 2026-01-07 20:42:10 +08:00
GitHub Action
4f7458b47d ignore: update download stats 2026-01-07 2026-01-07 12:04:44 +00:00
Brendan Allan
270cd05195 fix entitlements 2026-01-07 20:03:38 +08:00
Saeed Vaziry
24c933ae60 Add audio input and camera permissions to entitlements (#7117)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-01-07 17:56:46 +08:00
Brendan Allan
2b7a021ba3 desktop: appimage bundleMediaFramework 2026-01-07 17:39:35 +08:00
GitHub Action
cbf87c50b9 chore: generate 2026-01-07 08:48:34 +00:00
Brendan Allan
3c375b971e desktop: use Show instead of Suspense 2026-01-07 16:47:45 +08:00
Aiden Cline
6590c1641f add truncation for all tools 2026-01-07 02:01:32 -06:00
GitHub Action
0ffe496869 chore: generate 2026-01-07 07:46:40 +00:00
Frank
ce4e595881 wip: black 2026-01-07 02:45:57 -05:00
Frank
e91cc7e514 wip: black 2026-01-07 02:45:57 -05:00
GitHub Action
c961072d20 chore: generate 2026-01-07 07:43:43 +00:00
Aiden Cline
429240f439 ignore: add truncation funcs (#7178) 2026-01-07 01:43:06 -06:00
Github Action
a0dc90bfcc Update Nix flake.lock and hashes 2026-01-07 07:36:49 +00:00
Brendan Allan
6bac501be5 add ui package to desktop 2026-01-07 15:35:32 +08:00
Daniel Polito
b5be883758 Make General SubAgent not hidden (#6752) 2026-01-07 01:31:47 -06:00
Brendan Allan
0021a09ba8 try using forked tauri-cli again 2026-01-07 15:29:14 +08:00
usvimal
a8c2928a87 perf: show window immediately during desktop startup (#6734)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-01-07 15:19:01 +08:00
Brendan Allan
79f6910697 desktop: use correct tauri-cli branch 2026-01-07 14:36:43 +08:00
Aiden Cline
04cea9cf11 tweak: unserializable error handling 2026-01-07 00:29:41 -06:00
Brendan Allan
61c334f1fb desktop: use regular tauri cli again 2026-01-07 14:25:25 +08:00
Spoon
85ed329318 add agent color to inline task tool ascii char and highlight agent name (#7142) 2026-01-07 00:10:00 -06:00
Brendan Allan
37decee795 use custom tauri-cli on linux 2026-01-07 13:34:18 +08:00
Frank
5d0007ade4 wip: black 2026-01-07 00:08:49 -05:00
GitHub Action
31dd9fd13a chore: generate 2026-01-07 04:35:15 +00:00
Frank
23fc675ad5 wip: black 2026-01-06 23:34:12 -05:00
Frank
22b058a33d wip: black 2026-01-06 23:34:12 -05:00
GitHub Action
939c0940aa chore: generate 2026-01-07 04:29:50 +00:00
Sewer.
fd7b7eacd3 Added: Ability to hide subagents from primary agents system prompt. (#4773)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-06 22:29:17 -06:00
GitHub Action
eaa0826e7f chore: generate 2026-01-07 03:42:44 +00:00
Adam
f6055ad3d2 chore: fix types 2026-01-06 21:42:04 -06:00
Adam
761863ae35 chore(app): rework storage approach 2026-01-06 21:42:03 -06:00
NSPC911
dadc08ddc7 fix: escape backticks when passing to powershell (#7157) 2026-01-06 21:41:33 -06:00
Github Action
d7afb01d13 Update Nix flake.lock and hashes 2026-01-07 03:20:32 +00:00
GitHub Action
5168988156 chore: generate 2026-01-07 03:19:47 +00:00
Adam
9a3bd0ade1 fix(app): hide line numbers in diffs on mobile 2026-01-06 21:18:50 -06:00
Adam
4e5b0b00b0 fix(app): session navigation with shortcuts 2026-01-06 21:18:41 -06:00
Adam
7672b722ca fix(app): permission auto-accept should hide when not needed 2026-01-06 21:18:29 -06:00
Adam
488c3502a7 fix(app): terminal selection color contrast 2026-01-06 21:18:17 -06:00
Aiden Cline
a2f80f7c0d fix: add --use-system-ca flag in execArgv 2026-01-06 21:01:03 -06:00
Rafi Khardalian
dee0226741 fix(tui): restore attach session lookup behavior (#7150) 2026-01-06 20:59:12 -06:00
kamkm
58aba1c797 chore: Refactor style guide to use consistent language (#7139) 2026-01-06 17:54:16 -06:00
Sebastian Herrlinger
9906d42e1c remove hallucinated keybinding 2026-01-07 00:19:56 +01:00
GitHub Action
43eefbc349 chore: generate 2026-01-06 23:06:08 +00:00
Aiden Cline
625c9dae5c fix: ensure commands listing subagent work 2026-01-06 17:05:20 -06:00
opencode
b2341c2d9a release: v1.1.4 2026-01-06 22:06:38 +00:00
Daniel Polito
dadddcaf57 Desktop: Fix Big Messages (#7133) 2026-01-06 16:00:37 -06:00
Jay
f7b3371b02 docs: readme
Removed section about unrelated repository.
2026-01-06 16:31:39 -05:00
GitHub Action
409f8f678e chore: generate 2026-01-06 21:17:29 +00:00
Frank
dc62f9393a zen: fix rate limit 2026-01-06 16:16:35 -05:00
Spoon
32e0b612d9 adding timeout (#7128) 2026-01-06 15:00:34 -06:00
Mateusz Tymek
7d6ce6fc5e docs: add OpenCode-Obsidian plugin (#7129) 2026-01-06 15:00:22 -06:00
Justas Raudonius
ba105246ea fix(app): open links in new tab or browser (#7127) 2026-01-06 14:58:36 -06:00
Spoon
a10cc63403 feat: url based instructions (#7125) 2026-01-06 14:48:22 -06:00
Maik
cde06e90d0 chore: update stars count (#7120) 2026-01-06 14:28:47 -06:00
galkatz373
d4e7a88bba feat(cli): frecency file autocomplete (#6603) 2026-01-06 14:05:57 -06:00
Thomas Gormley
630476afc0 load OPENCODE_CONFIG_DIR AGENTS.md into the system prompt (#7115) 2026-01-06 13:43:55 -06:00
Adam
5181e4e90a fix(app): copy and paste in terminal was broken 2026-01-06 13:38:15 -06:00
Aiden Cline
5db78f20e9 core: fix title generation for subtask-only messages to extract actual user prompts instead of generic tool execution descriptions 2026-01-06 13:30:39 -06:00
Frank
5fc4472921 OpenCode Black 2026-01-06 14:22:54 -05:00
Damian Barabonkov
3049ac576a docs: Expand keybinds documentation (#7108) 2026-01-06 13:10:35 -06:00
Aiden Cline
494e03490e docs: fix desktop stuff 2026-01-06 13:07:20 -06:00
Aiden Cline
675eba6588 Revert "fix(desktop): use current_binary() to support symlinked executables (#7102)"
This reverts commit bb09df0c77.
2026-01-06 12:59:05 -06:00
Aiden Cline
01eadf3ded test: fix test 2026-01-06 12:31:41 -06:00
Aiden Cline
c7a2c737e8 fix: ensure 'name' isnt being sent in request body for custom agent 2026-01-06 12:21:30 -06:00
Jérôme Benoit
bb09df0c77 fix(desktop): use current_binary() to support symlinked executables (#7102) 2026-01-06 12:07:18 -06:00
Adam
ecbcbfbe90 fix(app): more contrast in terminal text 2026-01-06 12:03:24 -06:00
M. Adel Alhashemi
485aadcbfa fix: restore skill filtering by agent permissions (#7042) 2026-01-06 12:01:37 -06:00
Matthijs Wolting
d76d6db589 fix: add missing await for available skills in skill tool (#7072) 2026-01-06 11:56:49 -06:00
Akinfolami Akin-Alamu
aa612b27d4 feat(tui): add 'c' shortcut to copy device code in OAuth flow (#7020) 2026-01-06 11:54:49 -06:00
GitHub Action
a35c278424 chore: generate 2026-01-06 17:29:44 +00:00
Guofang.Tang
8265621d48 fix: prevent jdtls path checks from throwing (#7052) 2026-01-06 11:29:06 -06:00
ikeda-tomoya-swx
1016a52cf1 fix(provider): add jp. prefix auto-assignment for Tokyo region (ap-northeast-1) (#7053) 2026-01-06 11:18:34 -06:00
Guofang.Tang
d0a1e6fa46 docs: add Simplified Chinese README (#7055) 2026-01-06 11:18:13 -06:00
ryanwyler
f0e559c0ed fix: sidebar title padding to prevent scrollbar edge case (#7089) 2026-01-06 11:15:39 -06:00
Andrew Thal
528c6c1a75 docs(ecosystem): add opencode-devcontainers plugin (#7100) 2026-01-06 10:58:55 -06:00
Justas Raudonius
6092f8792e feat(app): add view button to open files from review sidebar (#7095) 2026-01-06 10:28:03 -06:00
Justas Raudonius
4142e1bcf6 fix(app): open review sidebar when selecting file from picker (#7096) 2026-01-06 10:27:22 -06:00
Justas Raudonius
49d837e0c1 feat(app): add middle-click to close tabs in review sidebar (#7094) 2026-01-06 10:15:17 -06:00
Adam
b88bcd49fd fix(app): code splitting for web load perf gains 2026-01-06 08:19:17 -06:00
Adam
3f463bc916 fix(app): scroll store performance 2026-01-06 08:18:49 -06:00
GitHub Action
0b02f6d22f ignore: update download stats 2026-01-06 2026-01-06 12:04:50 +00:00
Eric Guo
4ecb305820 Fix(app): @pierre/diffs will crash when a diff has undefined text (#7059) 2026-01-06 05:22:47 -06:00
Brendan Allan
e5a868157e update Cargo.lock 2026-01-06 17:08:34 +08:00
Junseo5
f510d17bd3 fix(desktop): add single-instance plugin to prevent multiple windows (#6966) 2026-01-06 14:19:29 +08:00
Beryl
45fea6587e fix: use actual version in install script (#7044) 2026-01-05 23:26:44 -06:00
Aiden Cline
a721810682 ci: tweak prompt 2026-01-05 23:24:58 -06:00
Shane Bishop
d486c1c7c8 docs: fix order of permissions in agents docs (permissions subsection) (#7041) 2026-01-05 23:10:50 -06:00
Guofang.Tang
9197a2a7a1 docs: polish markdown wording and capitalization (#7019)
Co-authored-by: Tang Guofang <tangguofang@mychery.com>
2026-01-05 22:45:40 -06:00
Frank
8da890649f wip: zen 2026-01-05 22:48:07 -05:00
GitHub Action
21053732e7 chore: generate 2026-01-05 23:10:23 +00:00
Frank
cf069dd046 wip: zen 2026-01-05 18:09:39 -05:00
Frank
4dc3cb9115 wip: zen 2026-01-05 18:09:38 -05:00
Adam
5c66c8b8e1 fix(core): filter dead worktrees 2026-01-05 16:37:18 -06:00
Adam
2ca0ae7755 fix(app): more defensive, handle no git 2026-01-05 15:12:02 -06:00
Github Action
19123b6803 Update Nix flake.lock and hashes 2026-01-05 20:19:14 +00:00
Sebastian Herrlinger
4137c66581 upgrade opentui to v0.1.69, with some text rendering performance improvements 2026-01-05 21:17:45 +01:00
GitHub Action
48d14d4cac chore: generate 2026-01-05 19:33:32 +00:00
OpeOginni
8996185f3b Feat/clickable subtask (#6846) 2026-01-05 13:32:16 -06:00
opencode
4f49967518 release: v1.1.3 2026-01-05 19:26:40 +00:00
Adam
ec637aa21e fix(app): store image attachments 2026-01-05 13:21:33 -06:00
Adam
2ff9a757b6 fix(app): bottom padding 2026-01-05 13:21:32 -06:00
Adam
cb8533ef5b fix(app): terminal long-running processes 2026-01-05 13:21:32 -06:00
Adam
cdbb009ab0 fix(app): terminal flakiness 2026-01-05 13:21:31 -06:00
Adam
001b486356 fix(app): performance improvements through event batching 2026-01-05 13:21:30 -06:00
Adam
d315026abc fix(app): prompt input shouldn't cover content 2026-01-05 13:21:30 -06:00
Adam
d1191675c6 fix(app): worktree selection should navigate to worktree 2026-01-05 13:21:29 -06:00
GitHub Action
362a657b4f chore: generate 2026-01-05 19:10:42 +00:00
Dax Raad
0276885181 core: preserve permission config key order to maintain user-defined permission precedence 2026-01-05 14:09:37 -05:00
Guorui Yu
5a38a6f248 tui: autocomplete: expand directory on Tab, select on Enter (#6975)
Signed-off-by: yuguorui <yuguorui@pku.edu.cn>
2026-01-05 13:08:17 -06:00
GitHub Action
aef01003e7 chore: generate 2026-01-05 19:07:34 +00:00
Melih Mucuk
a38e1701ee fix: pass image parts to custom commands (#6525)
Co-authored-by: Melih Mucuk <melih@monkeysteam.com>
2026-01-05 13:06:57 -06:00
ikeda-tomoya-swx
bf9ee32d4a fix(provider): add support for jp. prefix in Amazon Bedrock cross-region inference profiles (#6916) 2026-01-05 13:03:09 -06:00
Josh Thomas
0917991361 docs: update GHA examples to use actions/checkout@v6 (#6969) 2026-01-05 13:00:22 -06:00
Ravi Kumar
c6a241e331 ci: prevent duplicate PR check from flagging current PR as duplicate (#6924)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-05 12:59:42 -06:00
Rohan Mukherjee
4b7301e8ca fix: lucent-orng bg transparency for slash commands (#6938) 2026-01-05 12:56:49 -06:00
Daniel Vélez
1bf20f0a2b docs: add description for MCP command (#6944) 2026-01-05 12:56:00 -06:00
Grégoire Morpain
e3b4d4ad49 feat(bedrock): config options and authentication precedence (#6377) 2026-01-05 12:51:43 -06:00
MogamiTsuchikawa
6b207b09d6 fix(app): avoid unintended submits during IME composition (#6952) 2026-01-05 12:38:38 -06:00
Albin Groen
9771325026 feat(app): highlight collapsed active project in sidebar (#6958) 2026-01-05 12:37:46 -06:00
Albin Groen
bbd1c071c4 fix(app): fix flicker and navigation when collapsing/expanding projects (#6658) 2026-01-05 11:24:49 -06:00
Daniel Polito
8e9a0c4ad0 Desktop: Install CLI (#6526)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-01-06 01:07:46 +08:00
Frank
ced093e646 sync 2026-01-05 11:59:17 -05:00
Frank
283bdce358 sync 2026-01-05 11:13:59 -05:00
Dax Raad
91d5ce8bf3 tui: add system theme resolution and event handling 2026-01-05 10:38:35 -05:00
Frank
7f870cc9d4 wip: zen 2026-01-05 10:16:47 -05:00
David Hill
2cb3b0484b fix: cleaner interrupted experience (#6785)
Co-authored-by: Dax <mail@thdxr.com>
2026-01-05 09:53:19 -05:00
Frank
11b0df6b86 wip: zen 2026-01-05 06:16:04 -05:00
Frank
e15af828fa zen: optimize query 2026-01-05 05:58:39 -05:00
Albin Groen
265cbaea7c fix(app): fix image dragging in project edit dialog (#6700) 2026-01-05 04:54:11 -06:00
GitHub Action
d39ebbc947 chore: generate 2026-01-05 07:41:02 +00:00
Aiden Cline
06acd70670 tweak: transform 2026-01-05 01:40:15 -06:00
Aiden Cline
c285304acf fix: for anthropic compat ensure empty msgs and empty reasoning is filtered out 2026-01-05 01:40:15 -06:00
opencode
4d187af9d2 release: v1.1.2 2026-01-05 07:16:26 +00:00
Aiden Cline
7e14cc687a ci: fix OPENCODE_PERMISSION env vars 2026-01-05 00:40:34 -06:00
Dax Raad
2f5b2b23d5 core: fix permission rule matching to use permission field instead of pattern field 2026-01-05 01:21:49 -05:00
Aiden Cline
035baa4b38 ignore: add codeowners file for adam 2026-01-05 00:17:32 -06:00
Dax Raad
9f38af44db core: fix permission evaluation to use rule-based matching instead of wildcard patterns 2026-01-05 01:07:03 -05:00
Rafi Khardalian
7324b2260a fix(tui): allow exit when viewing child session (#6898)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-04 23:14:43 -06:00
GitHub Action
166f169dbf chore: generate 2026-01-05 03:47:12 +00:00
Frank
9c55cb729b zen: add index 2026-01-04 22:46:21 -05:00
Aiden Cline
f2e65e40ea fix: handle skill scan failures for .claude gracefully 2026-01-04 21:39:45 -06:00
Aiden Cline
8b3ae08a55 acp: handle case where big-pickle is unavailable as a fallback 2026-01-04 21:10:30 -06:00
Aiden Cline
555d7fcdde ci: make sure opencode is installed 2026-01-04 20:35:41 -06:00
opencode-agent[bot]
2410a6bc9e Fix symmetric padding in TUI input field (#6894)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-04 20:34:14 -06:00
GitHub Action
59ed8ccbd8 chore: generate 2026-01-05 02:18:20 +00:00
OpeOginni
91ed101378 feat(desktop): implement auto-scroll for active command in slash popover (#6797) 2026-01-04 20:17:47 -06:00
Daniel Polito
fb60f9c396 Desktop: Fix Responsive Menu (#6789) 2026-01-04 18:47:14 -06:00
Shkumbin Hasani
e93699b741 perf: optimize model dialog visibility lookups (#6791) 2026-01-04 18:46:23 -06:00
Daniel Polito
9ac00f55bc Desktop: Adding Home Icon on Responsive Menu (#6794) 2026-01-04 18:44:28 -06:00
Daniel Polito
393cf78ca6 Desktop: Improve Big Session Navigation - Scrollable (#6837) 2026-01-04 18:40:58 -06:00
GitHub Action
478fec61ab chore: generate 2026-01-05 00:39:48 +00:00
shuv
52ad134d55 feat(app): add SVG preview support in session viewer (#6868) 2026-01-04 18:39:15 -06:00
NN708
3e09abbfda feat(desktop): add AppStream MetaInfo file (#6030) 2026-01-04 18:36:07 -06:00
Dax Raad
5450644c67 docs: restructure permissions documentation to clarify v1.1.1 changes and action-based model 2026-01-04 19:35:04 -05:00
Carter McBride
0c2ccf25dc Fix a few mobile screen size issues (#6808) 2026-01-04 18:32:48 -06:00
Ravi Kumar
65c7168492 fix(app): fix custom slash commands not showing on initial / (#6829) 2026-01-04 18:30:34 -06:00
Albin Groen
c74c66e6b4 fix(ui): fix select chevron alignment (#6690) 2026-01-04 18:29:19 -06:00
Aiden Cline
c545fa2a28 ci: nix desktop 2026-01-04 13:52:32 -06:00
Aiden Cline
80235f325e ci: fix dup pr action 2026-01-04 13:30:58 -06:00
Rohan Godha
88c306efd2 fix: prevent session list rows from wrapping to 2 lines (#6812) 2026-01-04 13:29:44 -06:00
Melih Mucuk
554572bc39 fix: prevent main model thinking variant from applying to small model (#6839)
Co-authored-by: Melih Mucuk <melih@monkeysteam.com>
2026-01-04 13:28:22 -06:00
Aiden Cline
e5abe1e78b tweak: bump default to 30 seconds (lots of people complained about 5...) 2026-01-04 13:26:43 -06:00
Aiden Cline
1d54f90330 docs: add instructions for running web and desktop apps during development 2026-01-04 13:12:43 -06:00
Dax Raad
5f10243e91 tui: fix session configuration merge conflict resolution 2026-01-04 13:43:33 -05:00
Dax Raad
226a5c2000 tui: fix optional session access to prevent runtime errors 2026-01-04 13:43:33 -05:00
Github Action
f8442ad016 Update Nix flake.lock and hashes 2026-01-04 18:39:44 +00:00
GitHub Action
1e28d10610 chore: generate 2026-01-04 18:39:08 +00:00
Dax Raad
7304ba616e tui: add session search functionality with debounced input and server-side filtering 2026-01-04 13:38:30 -05:00
Dax Raad
cdd6ea514b core: improve Rust formatter detection and add cargo fmt support 2026-01-04 13:04:28 -05:00
GitHub Action
24d9c1d18d chore: generate 2026-01-04 17:09:30 +00:00
Adam
5ca2f6c5a9 fix(app): prompt input improvements 2026-01-04 11:08:47 -06:00
Adam
12ffb270fb fix(app): prompt input improvements 2026-01-04 10:37:56 -06:00
opencode
dc25669b6e release: v1.1.1 2026-01-04 15:52:55 +00:00
Github Action
0f9130b649 Update Nix flake.lock and hashes 2026-01-04 15:39:15 +00:00
Dax Raad
a76570b5dd tui: add development scripts for better debugging workflow 2026-01-04 10:38:02 -05:00
Dax Raad
97977f6ad4 ensure @opencode-ai/plugin exists only on first run 2026-01-04 10:23:42 -05:00
GitHub Action
555a5ccb59 chore: generate 2026-01-04 15:13:52 +00:00
Adam Spiers
24dedb4f7b fix(tui): add missing theme_list keybind (#6779)
Co-authored-by: Adam Spiers <opencode@adamspiers.org>
2026-01-04 09:13:19 -06:00
Paolo Ricciuti
21dc3c24d9 feat: mcp resources (#6542) 2026-01-04 09:12:54 -06:00
Jérôme Benoit
e00621cb17 feat(nix): preliminary desktop app flake integration (#6135)
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>
2026-01-04 09:11:05 -06:00
Dax Raad
2d074f0472 initialize config in worktree 2026-01-04 10:10:25 -05:00
Felipe Orlando
f3cd3b8941 Remove opencode-skills entry from ecosystem.mdx (#6817) 2026-01-04 08:43:36 -06:00
John Connor
1f8dab50be docs: typo in subtask documentation (#6821) 2026-01-04 08:43:03 -06:00
Aiden Cline
29672e7b95 ci: update duplicate pr action 2026-01-04 08:36:21 -06:00
GitHub Action
4f3ac709a4 chore: generate 2026-01-04 14:22:48 +00:00
Matt Silverlock
8aa56dc01d docs: add logging best practices for plugin authors (#6833) 2026-01-04 08:22:14 -06:00
Aiden Cline
d72d7ab510 tweak: prioritize free gpt-5-mini for small model in github copilot 2026-01-04 08:21:09 -06:00
Adam
5053822bd6 fix(app): auto-scroll 2026-01-04 06:14:24 -06:00
Adam
177b01a853 fix(app): scroll position restoration 2026-01-04 04:53:55 -06:00
Adam
c9f907caec fix(app): don't override ctrl+a on windows 2026-01-04 04:35:26 -06:00
Adam
7ce0520f8d fix(app): auto-scroll behaviors 2026-01-04 04:24:37 -06:00
Matt Silverlock
4486174e43 github: handle duplicate PR creation when agent creates PR (#6777) 2026-01-04 02:05:08 -06:00
Aiden Cline
41cf45a16e tui: fix system theme diff highlighting
- Generate distinct red/green backgrounds for added/removed lines in system theme
- Use bright ANSI colors for diff highlights to improve visibility
- Fix ANSI palette indexing to handle null entries safely
- Add color tinting to create proper diff backgrounds while respecting terminal colors

Resolves issue where system theme showed no red/green diff highlighting
2026-01-04 02:01:02 -06:00
Aiden Cline
3611260405 core: remove hardcoded .env read block and use new permissions model instead 2026-01-04 01:49:49 -06:00
Shpetim
c3fd3c8656 fix(plugin): prevent duplicate plugin function initialization (#6787)
Co-authored-by: Shpetim <shpetim.alimi@ndbit.net>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-04 01:39:54 -06:00
ben
4d7d28c30a docs: Add opencode-scheduler plugin to ecosystem (#6804)
Co-authored-by: Benjamin Shafii <benjaminshafii@home-server.local>
2026-01-04 01:08:14 -06:00
Aiden Cline
96a00ffea9 core: update github copilot model model priority list 2026-01-04 00:57:35 -06:00
Aiden Cline
02540b2464 ignore: update sst -> anomalyco 2026-01-04 00:30:03 -06:00
Aiden Cline
5aa4fd0042 core: add variant to chat.message input 2026-01-04 00:28:52 -06:00
Aiden Cline
b934c22d8d ci: add duplicate PR detection bot 2026-01-04 00:15:59 -06:00
shuv
72cef0d9e7 feat: add --variant flag to run command (#6805)
Co-authored-by: shuv <shuv@shuv.dev>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-04 00:08:34 -06:00
Frank
d3fd6d1a10 zen: update models 2026-01-04 00:58:06 -05:00
jerilynzheng
6b12a0084c docs: Add Vercel AI Gateway to provider docs (#6790) 2026-01-03 20:57:26 -06:00
GitHub Action
a5a19197f5 chore: generate 2026-01-04 02:57:20 +00:00
Saatvik Arya
74d0d2b942 docs: update AGENTS.md (#6800) 2026-01-03 20:56:47 -06:00
Adam
235837d2d9 fix(app): diff rendering performance 2026-01-03 19:57:59 -06:00
Dax Raad
dcf37000e4 tui: remove openrouter provider from priority list 2026-01-03 20:45:15 -05:00
Dax Raad
5944443a60 core: fix dependency installation and git worktree branch creation 2026-01-03 20:22:19 -05:00
Dax Raad
81e8d29ad2 oops 2026-01-03 19:25:59 -05:00
GitHub Action
8b6cf7081f chore: generate 2026-01-03 23:53:29 +00:00
Dax Raad
0b4af95223 core: add sandbox support for git worktrees to allow working in multiple directories per project 2026-01-03 18:52:53 -05:00
Mani Sundararajan
f6cc84747a fix(tui): make lsp status icon muted when no lsps are active (#6773) 2026-01-03 14:56:29 -06:00
Rhys Sullivan
586e7347bd fix(mcp): add timeout to client.connect() calls (#6760) 2026-01-03 11:54:24 -06:00
Osinachi Okpara
69d4ef038b docs: enhance MCP servers documentation with a tip (#6713) 2026-01-03 11:02:05 -06:00
Daniel Polito
c7c1790da8 Desktop: Edit Project Fix (#6757) 2026-01-03 10:26:30 -06:00
Mani Sundararajan
12eea69f2e fix(tui): make mcp status icon muted when no mcp servers are enabled (#6745) 2026-01-03 10:23:09 -06:00
OpeOginni
308e8060dc fix(server): update server URL normalization to retain path (#6647) 2026-01-03 09:50:15 -06:00
shuv
5f93beed77 feat(app): add image preview support in session viewer (#6678) 2026-01-03 05:46:42 -06:00
GitHub Action
527553ada2 chore: generate 2026-01-03 07:16:11 +00:00
Jake Nelson
5c5e636030 feat: add per-project MCP config overrides (#5406)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-03 01:15:37 -06:00
Gabriel Patzleiner
da6df3d432 fix(kotlin-ls): improve root detection for Gradle multi-project builds (#6717) 2026-01-03 01:14:01 -06:00
Aiden Cline
b9b0e3475c core: improve plugin loading to handle builtin plugin failures gracefully (#6739) 2026-01-03 00:54:35 -06:00
GitHub Action
77fcefca0e chore: generate 2026-01-03 06:35:01 +00:00
Dax Raad
47c670aea9 tui: add reject message support to permission dialogs for better user feedback 2026-01-03 01:34:23 -05:00
Aiden Cline
2b66b31d96 ignore: update bug report template 2026-01-03 00:20:43 -06:00
Aiden Cline
f991fbbde8 core: ephemerally wrap queued user messages with reminder to stay on track (#6725) 2026-01-02 22:42:56 -06:00
shuv
401b498c7d fix(tui): pass attach directory to sdk client (#6715)
Co-authored-by: shuv <shuv@shuv.dev>
2026-01-02 21:54:11 -06:00
opencode-agent[bot]
f2ec036027 docs: rm incorrect -p alias from docs (#6721)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-02 21:11:42 -06:00
GitHub Action
a235aec9ab chore: generate 2026-01-03 02:17:34 +00:00
Adam
052de3c556 feat: add managed git worktrees (#6674) 2026-01-02 20:17:02 -06:00
Github Action
f6fe709f6e Update Nix flake.lock and hashes 2026-01-03 00:10:15 +00:00
Sebastian Herrlinger
ff0bd84870 upgrade opentui to v0.1.68, using gpa 2026-01-03 01:08:58 +01:00
Dax Raad
b4af8a65ec ci 2026-01-02 18:58:56 -05:00
Dax Raad
49c5c2b1df ci 2026-01-02 18:56:41 -05:00
Dax Raad
4956ee3ebd tui: add escape key handling to permission dialogs for better keyboard navigation 2026-01-02 18:48:26 -05:00
GitHub Action
1261b7d333 chore: generate 2026-01-02 22:58:02 +00:00
YeonGyu-Kim
a3f38e0533 feat(plugin): add tui.session.select API endpoint for TUI navigation (#6565)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-02 16:57:21 -06:00
GitHub Action
681a257df6 chore: generate 2026-01-02 22:46:22 +00:00
Troy Gaines
586207adb4 feat: Add kotlin lsp integration (#6601) 2026-01-02 16:45:44 -06:00
theavgjojo
a58dbb3b5c chore: add license field to package.json (#6693)
Co-authored-by: theavgjojo <jojo@noreply>
2026-01-02 16:29:09 -06:00
Spoon
131d8e5778 docs: add subtask2 to ecosystem page (#6704) 2026-01-02 16:26:06 -06:00
266 changed files with 50244 additions and 3573 deletions

4
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,4 @@
# web + desktop packages
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/ @adamdotdevin

View File

@@ -11,6 +11,14 @@ body:
validations:
required: true
- type: input
id: plugins
attributes:
label: Plugins
description: What plugins are you using?
validations:
required: false
- type: input
id: opencode-version
attributes:

View File

@@ -28,8 +28,8 @@ jobs:
OPENCODE_PERMISSION: |
{
"bash": {
"gh issue*": "allow",
"*": "deny"
"*": "deny",
"gh issue*": "allow"
},
"webfetch": "deny"
}

65
.github/workflows/duplicate-prs.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Duplicate PR Check
on:
pull_request_target:
types: [opened]
jobs:
check-duplicates:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install dependencies
run: bun install
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Build prompt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
{
echo "Check for duplicate PRs related to this new PR:"
echo ""
echo "CURRENT_PR_NUMBER: $PR_NUMBER"
echo ""
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
echo ""
echo "Description:"
gh pr view "$PR_NUMBER" --json body --jq .body
} > pr_info.txt
- name: Check for duplicate PRs
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
$COMMENT"

35
.github/workflows/nix-desktop.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: nix desktop
on:
push:
branches: [dev]
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
workflow_dispatch:
jobs:
build-desktop:
strategy:
fail-fast: false
matrix:
os:
- blacksmith-4vcpu-ubuntu-2404
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: DeterminateSystems/nix-installer-action@v21
- name: Build desktop via flake
run: |
set -euo pipefail
nix --version
nix build .#desktop -L

View File

@@ -31,7 +31,7 @@ permissions:
jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'sst/opencode'
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:

View File

@@ -47,7 +47,7 @@ jobs:
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "gh pr review*": "deny", "*": "deny" } }'
OPENCODE_PERMISSION: '{ "bash": { "*": "deny", "gh*": "allow", "gh pr review*": "deny" } }'
PR_TITLE: ${{ steps.pr-details.outputs.title }}
run: |
PR_BODY=$(jq -r .body pr_data.json)

View File

@@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
stats:
if: github.repository == 'sst/opencode'
if: github.repository == 'anomalyco/opencode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write

View File

@@ -0,0 +1,26 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
color: "#E67E22"
tools:
"*": false
"github-pr-search": true
---
You are a duplicate PR detection agent. When a PR is opened, your job is to search for potentially duplicate or related open PRs.
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature.
IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself.
Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
If you find potential duplicates:
- List them with their titles and URLs
- Briefly explain why they might be related
If no duplicates are found, say so clearly. BUT ONLY SAY "No duplicate PRs found" (don't say anything else if no dups)
Keep your response concise and actionable.

View File

@@ -3,7 +3,7 @@ description: "find issue(s) on github"
model: opencode/claude-haiku-4-5
---
Search through existing issues in sst/opencode using the gh cli to find issues matching this query:
Search through existing issues in anomalyco/opencode using the gh cli to find issues matching this query:
$ARGUMENTS

View File

@@ -10,11 +10,6 @@
"options": {},
},
},
"permission": {
"bash": {
"ls foo": "ask",
},
},
"mcp": {
"context7": {
"type": "remote",
@@ -23,5 +18,6 @@
},
"tools": {
"github-triage": false,
"github-pr-search": false,
},
}

View File

@@ -0,0 +1,57 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...options.headers,
},
})
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
interface PR {
title: string
html_url: string
}
export default tool({
description: DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
offset: tool.schema.number().describe("Number of results to skip for pagination").default(0),
},
async execute(args) {
const owner = "anomalyco"
const repo = "opencode"
const page = Math.floor(args.offset / args.limit) + 1
const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:pr state:open`)
const result = await githubFetch(
`/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`,
)
if (result.total_count === 0) {
return `No PRs found matching "${args.query}"`
}
const prs = result.items as PR[]
if (prs.length === 0) {
return `No other PRs found matching "${args.query}"`
}
const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
},
})

View File

@@ -0,0 +1,10 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.

View File

@@ -40,7 +40,7 @@ export default tool({
async execute(args) {
const issue = getIssueNumber()
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
const owner = "sst"
const owner = "anomalyco"
const repo = "opencode"
const results: string[] = []

View File

@@ -67,8 +67,49 @@ Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
- Core pieces:
- `packages/opencode`: OpenCode core business logic & server.
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
- `packages/app`: The shared web UI components, written in SolidJS
- `packages/desktop`: The native desktop app, built with Tauri (wraps `packages/app`)
- `packages/plugin`: Source for `@opencode-ai/plugin`
### Running the Web App
To test UI changes during development, run the web app:
```bash
bun run --cwd packages/app dev
```
This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here.
### Running the Desktop App
The desktop app is a native Tauri application that wraps the web UI.
To run the native desktop app:
```bash
bun run --cwd packages/desktop tauri dev
```
This starts the web dev server on http://localhost:1420 and opens the native window.
If you only want the web dev server (no native shell):
```bash
bun run --cwd packages/desktop dev
```
To create a production `dist/` and build the native app bundle:
```bash
bun run --cwd packages/desktop tauri build
```
This runs `bun run --cwd packages/desktop build` automatically via Tauris `beforeBuildCommand`.
> [!NOTE]
> Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
> [!NOTE]
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.

View File

@@ -28,7 +28,8 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS and Linux
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less frequently)
paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
@@ -70,8 +71,7 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
### Agents
OpenCode includes two built-in agents you can switch between,
you can switch between these using the `Tab` key.
OpenCode includes two built-in agents you can switch between with the `Tab` key.
- **build** - Default, full access agent for development work
- **plan** - Read-only agent for analysis and code exploration
@@ -108,10 +108,6 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
#### What's the other repo?
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
---
**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

116
README.zh-CN.md Normal file
View File

@@ -0,0 +1,116 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">开源的 AI Coding Agent。</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### 安装
```bash
# 直接安装 (YOLO)
curl -fsSL https://opencode.ai/install | bash
# 软件包管理器
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux推荐始终保持最新
brew install opencode # macOS 和 Linux官方 brew formula更新频率较低
paru -S opencode-bin # Arch Linux
mise use -g opencode # 任意系统
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
```
> [!TIP]
> 安装前请先移除 0.1.x 之前的旧版本。
### 桌面应用程序 (BETA)
OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下载。
| 平台 | 下载文件 |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb``.rpm` 或 AppImage |
```bash
# macOS (Homebrew Cask)
brew install --cask opencode-desktop
```
#### 安装目录
安装脚本按照以下优先级决定安装路径:
1. `$OPENCODE_INSTALL_DIR` - 自定义安装目录
2. `$XDG_BIN_DIR` - 符合 XDG 基础目录规范的路径
3. `$HOME/bin` - 如果存在或可创建的用户二进制目录
4. `$HOME/.opencode/bin` - 默认备用路径
```bash
# 示例
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Agents
OpenCode 内置两种 Agent可用 `Tab` 键快速切换:
- **build** - 默认模式,具备完整权限,适合开发工作
- **plan** - 只读模式,适合代码分析与探索
- 默认拒绝修改文件
- 运行 bash 命令前会询问
- 便于探索未知代码库或规划改动
另外还包含一个 **general** 子 Agent用于复杂搜索和多步任务内部使用也可在消息中输入 `@general` 调用。
了解更多 [Agents](https://opencode.ai/docs/agents) 相关信息。
### 文档
更多配置说明请查看我们的 [**官方文档**](https://opencode.ai/docs)。
### 参与贡献
如有兴趣贡献代码,请在提交 PR 前阅读 [贡献指南 (Contributing Docs)](./CONTRIBUTING.md)。
### 基于 OpenCode 进行开发
如果你在项目名中使用了 “opencode”如 “opencode-dashboard” 或 “opencode-mobile”请在 README 里注明该项目不是 OpenCode 团队官方开发,且不存在隶属关系。
### 常见问题 (FAQ)
#### 这和 Claude Code 有什么不同?
功能上很相似,关键差异:
- 100% 开源。
- 不绑定特定提供商。推荐使用 [OpenCode Zen](https://opencode.ai/zen) 的模型,但也可搭配 Claude、OpenAI、Google 甚至本地模型。模型迭代会缩小差异、降低成本,因此保持 provider-agnostic 很重要。
- 内置 LSP 支持。
- 聚焦终端界面 (TUI)。OpenCode 由 Neovim 爱好者和 [terminal.shop](https://terminal.shop) 的创建者打造,会持续探索终端的极限。
- 客户端/服务器架构。可在本机运行同时用移动设备远程驱动。TUI 只是众多潜在客户端之一。
#### 另一个同名的仓库是什么?
另一个名字相近的仓库与本项目无关。[点击这里了解背后故事](https://x.com/thdxr/status/1933561254481666466)。
---
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

View File

@@ -28,7 +28,8 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS 與 Linux
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
brew install opencode # macOS 與 Linux官方 brew formula更新頻率較低
paru -S opencode-bin # Arch Linux
mise use -g github:anomalyco/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支

383
STATS.md
View File

@@ -1,191 +1,196 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ------------------- | ------------------- | ------------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
| 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) |
| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) |
| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) |
| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) |
| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) |
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | -------------------- | ------------------- | -------------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
| 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) |
| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) |
| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) |
| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) |
| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) |
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) |
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |

View File

@@ -1,9 +1,7 @@
## Style Guide
- Try to keep things in one function unless composable or reusable
- DO NOT do unnecessary destructuring of variables
- DO NOT use `else` statements unless necessary
- DO NOT use `try`/`catch` if it can be avoided
- AVOID unnecessary destructuring of variables
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type

View File

@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -173,9 +173,10 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
@@ -201,7 +202,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -230,7 +231,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -246,7 +247,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.224",
"version": "1.1.6",
"bin": {
"opencode": "./bin/opencode",
},
@@ -276,7 +277,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.15.1",
"@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -285,11 +286,12 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.67",
"@opentui/solid": "0.1.67",
"@opentui/core": "0.1.69",
"@opentui/solid": "0.1.69",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
@@ -348,7 +350,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -368,7 +370,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.224",
"version": "1.1.6",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -379,7 +381,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -392,7 +394,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -400,6 +402,7 @@
"@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -430,7 +433,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"zod": "catalog:",
},
@@ -441,7 +444,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.224",
"version": "1.1.6",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -908,6 +911,8 @@
"@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.88.1", "", { "dependencies": { "@hey-api/codegen-core": "^0.3.3", "@hey-api/json-schema-ref-parser": "1.2.2", "ansi-colors": "4.1.3", "c12": "3.3.2", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-x/nDTupOnV9VuSeNIiJpgIpc915GHduhyseJeMTnI0JMsXaObmpa0rgPr3ASVEYMLgpvqozIEG1RTOOnal6zLQ=="],
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
@@ -1092,7 +1097,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.15.1", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -1196,21 +1201,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.67", "", { "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.67", "@opentui/core-darwin-x64": "0.1.67", "@opentui/core-linux-arm64": "0.1.67", "@opentui/core-linux-x64": "0.1.67", "@opentui/core-win32-arm64": "0.1.67", "@opentui/core-win32-x64": "0.1.67", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-zmfyA10QUbzT6ohacPoHmGiYzuJrDSCfQWRWrKtao0BrHj9bii73qWy3V/eR4ibVueoRREwxJs5GlBOSvK6IoA=="],
"@opentui/core": ["@opentui/core@0.1.69", "", { "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.69", "@opentui/core-darwin-x64": "0.1.69", "@opentui/core-linux-arm64": "0.1.69", "@opentui/core-linux-x64": "0.1.69", "@opentui/core-win32-arm64": "0.1.69", "@opentui/core-win32-x64": "0.1.69", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-BcEFnAuMq4vgfb+zxOP/l+NO1AS3fVHkYjn+E8Wpmaxr0AzWNTi2NPAMtQf+Wqufxo0NYh0gY4c9B6n8OxTjGw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.67", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LtOcTlFD+kO7neItmkiF77H8cnjTYzBOZe8JQGwRSt9aaCke3UzMvLxmQnj4BP/kPC3hi9V6NRnFdptz0sJZIQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.69", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d9RPAh84O2XIyMw+7+X0fEyi+4KH5sPk9AxLze8GHRBGOzkRunqagFCLBrN5VFs2e2nbhIYtjMszo7gcpWyh7g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.67", "", { "os": "darwin", "cpu": "x64" }, "sha512-9i+awVWgpEVqZhFLaLq8usNGyCiyT5QxMLy6eH7JmRic79S34u23HfxiniGRtdYh3aqpm9SbLzo60v0nRIUkCA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.69", "", { "os": "darwin", "cpu": "x64" }, "sha512-41K9zkL2IG0ahL+8Gd+e9ulMrnJF6lArPzG7grjWzo+FWEZwvw0WLCO1/Gn5K85G8Yx7gQXkZOUaw1BmHjxoRw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.67", "", { "os": "linux", "cpu": "arm64" }, "sha512-WLjnTM3Ig//SRo0FUZYZJ5TITVbR6dKDVg6axU2D+sMoUzJMBP/Xo04q/TvZ3wP764Yca9l7oVMKWDxHlygyjQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.69", "", { "os": "linux", "cpu": "arm64" }, "sha512-IcUjwjuIpX3BBG1a9kjMqWrHYCFHAVfjh5nIRozWZZoqaczLzJb3nJeF2eg8aDeIoGhXvERWB1r1gmqPW8u3vQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.67", "", { "os": "linux", "cpu": "x64" }, "sha512-5UbZ/TqWi/DAmHIZL4NvhdpgTwglszRiddkRiQ8cT0IbnE4lutd4XxWUWcLKwsNT1YJv32TtcGWkuthluLiriQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.69", "", { "os": "linux", "cpu": "x64" }, "sha512-5S9vqEIq7q+MEdp4cT0HLegBWu0pWLcletHZL80bsLbJt9OT8en3sQmL5bvas9sIuyeBFru9bfCmrQ/gnVTTiA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.67", "", { "os": "win32", "cpu": "arm64" }, "sha512-KNam5rObhN8/U9+GVVuvtAlGXp3MfdMHnw4W2P6YH7xp8HTsLvABUT91SJEyJ/ktVe9e1itLDG2fDHSoA5NbUg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.69", "", { "os": "win32", "cpu": "arm64" }, "sha512-eSKcGwbcnJJPtrTFJI7STZ7inSYeedHS0swwjZhh9SADAruEz08intamunOslffv5+mnlvRp7UBGK35cMjbv/w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.67", "", { "os": "win32", "cpu": "x64" }, "sha512-740lkOw42zLNh9YfahXjCwV2DS/amH2uMDh3tCADDCLckrMhemIhqArXDiMlalDxDqYspoaZCpBsFVsG9dMS6A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.69", "", { "os": "win32", "cpu": "x64" }, "sha512-OjG/0jqYXURqbbUwNgSPrBA6yuKF3OOFh8JSG7VvzoYHJFJRmwVWY0fztWv/hgGHe354ti37c7JDJBQ44HOCdA=="],
"@opentui/solid": ["@opentui/solid@0.1.67", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.67", "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-dVNq0+PJIdNb63D0T7vcbyVF/ZvLCihGvivTU50zDOzd0Sk5prbrIfpG8+DjMErFubXfdZQvdy/PqFdtw0rjtQ=="],
"@opentui/solid": ["@opentui/solid@0.1.69", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.69", "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-ls589N8P9gvcNW8uF+Il4xisF5Uouk0RRmSaLdzmItNJSW5J9Y0nPtMELta6hBp0yIRAurWUO1wtkKXVF+eaxg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1616,6 +1621,8 @@
"@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ=="],
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ejq/Z7zKo/6eIEFr1bFLzXFxiGBCMLuqCM8QB8urr3YdPzjSETFLzYRWUyRiDWaBQN0F7k0SY6S7ig5nWOP7vg=="],
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw=="],
@@ -1898,7 +1905,9 @@
"ai": ["ai@5.0.97", "", { "dependencies": { "@ai-sdk/gateway": "2.0.12", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8zBx0b/owis4eJI2tAlV8a1Rv0BANmLxontcAelkLNwEHhgfgXeKpDkhNB6OgV+BJSwboIUDkgd9312DdJnCOQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -2402,7 +2411,7 @@
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="],
@@ -2778,7 +2787,9 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -3380,6 +3391,8 @@
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
@@ -3754,8 +3767,6 @@
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@@ -4054,9 +4065,11 @@
"@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="],
@@ -4400,8 +4413,6 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1767273430,
"narHash": "sha256-kDpoFwQ8GLrPiS3KL+sAwreXrph2KhdXuJzo5+vSLoo=",
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "76eec3925eb9bbe193934987d3285473dbcfad50",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"type": "github"
},
"original": {

View File

@@ -66,10 +66,10 @@
mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
hash = nodeModulesHash;
};
mkPackage = pkgs.callPackage ./nix/opencode.nix { };
in
{
default = mkPackage {
mkOpencode = pkgs.callPackage ./nix/opencode.nix { };
mkDesktop = pkgs.callPackage ./nix/desktop.nix { };
opencodePkg = mkOpencode {
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
@@ -77,6 +77,18 @@
modelsDev = "${modelsDev.${system}}/dist/_api.json";
inherit mkNodeModules;
};
desktopPkg = mkDesktop {
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
mkNodeModules = mkNodeModules;
opencode = opencodePkg;
};
in
{
default = opencodePkg;
desktop = desktopPkg;
}
);

View File

@@ -82,7 +82,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1

View File

@@ -3,6 +3,7 @@
"module": "index.ts",
"type": "module",
"private": true,
"license": "MIT",
"devDependencies": {
"@types/bun": "catalog:"
},

View File

@@ -76,6 +76,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
"checkout.session.completed",
"checkout.session.expired",
"charge.refunded",
"invoice.payment_succeeded",
"customer.created",
"customer.deleted",
"customer.updated",
@@ -97,6 +98,19 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
],
})
const zenProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Black",
})
const zenPrice = new stripe.Price("ZenBlackPrice", {
product: zenProduct.id,
unitAmount: 20000,
currency: "usd",
recurring: {
interval: "month",
intervalCount: 1,
},
})
const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"),
new sst.Secret("ZEN_MODELS2"),
@@ -104,7 +118,9 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS4"),
new sst.Secret("ZEN_MODELS5"),
new sst.Secret("ZEN_MODELS6"),
new sst.Secret("ZEN_MODELS7"),
]
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
@@ -146,6 +162,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK,
...ZEN_MODELS,
...($dev
? [

263
install
View File

@@ -16,16 +16,19 @@ Usage: install.sh [options]
Options:
-h, --help Display this help message
-v, --version <version> Install a specific version (e.g., 1.0.180)
-b, --binary <path> Install from a local binary instead of downloading
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
Examples:
curl -fsSL https://opencode.ai/install | bash
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
./install --binary /path/to/opencode
EOF
}
requested_version=${VERSION:-}
no_modify_path=false
binary_path=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do
exit 1
fi
;;
-b|--binary)
if [[ -n "${2:-}" ]]; then
binary_path="$2"
shift 2
else
echo -e "${RED}Error: --binary requires a path argument${NC}"
exit 1
fi
;;
--no-modify-path)
no_modify_path=true
shift
@@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do
esac
done
raw_os=$(uname -s)
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
case "$raw_os" in
Darwin*) os="darwin" ;;
Linux*) os="linux" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
esac
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
fi
if [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
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
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
;;
*)
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}"
exit 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
# If --binary is provided, skip all download/detection logic
if [ -n "$binary_path" ]; then
if [ ! -f "$binary_path" ]; then
echo -e "${RED}Error: Binary not found at ${binary_path}${NC}"
exit 1
fi
specific_version="local"
else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
# Verify the release exists before downloading
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
raw_os=$(uname -s)
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
case "$raw_os" in
Darwin*) os="darwin" ;;
Linux*) os="linux" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
esac
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
fi
if [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
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
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
;;
*)
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}"
exit 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
if [ -z "$requested_version" ]; then
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
exit 1
fi
else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
# Verify the release exists before downloading
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
exit 1
fi
fi
fi
@@ -187,11 +208,8 @@ check_version() {
if command -v opencode >/dev/null 2>&1; then
opencode_path=$(which opencode)
## TODO: check if version is installed
# installed_version=$(opencode version)
installed_version="0.0.1"
installed_version=$(echo $installed_version | awk '{print $2}')
## Check the installed version
installed_version=$(opencode --version 2>/dev/null || echo "")
if [[ "$installed_version" != "$specific_version" ]]; then
print_message info "${MUTED}Installed version: ${NC}$installed_version."
@@ -267,11 +285,11 @@ download_with_progress() {
{
local length=0
local bytes=0
while IFS=" " read -r -a line; do
[ "${#line[@]}" -lt 2 ] && continue
local tag="${line[0]} ${line[1]}"
if [ "$tag" = "0000: content-length:" ]; then
length="${line[2]}"
length=$(echo "$length" | tr -d '\r')
@@ -296,7 +314,7 @@ download_and_install() {
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
mkdir -p "$tmp_dir"
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
curl -# -L -o "$tmp_dir/$filename" "$url"
@@ -307,14 +325,24 @@ download_and_install() {
else
unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
fi
mv "$tmp_dir/opencode" "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode"
rm -rf "$tmp_dir"
}
check_version
download_and_install
install_from_binary() {
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path"
cp "$binary_path" "${INSTALL_DIR}/opencode"
chmod 755 "${INSTALL_DIR}/opencode"
}
if [ -n "$binary_path" ]; then
install_from_binary
else
check_version
download_and_install
fi
add_to_path() {
@@ -416,4 +444,3 @@ echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
echo -e ""
echo -e ""

145
nix/desktop.nix Normal file
View File

@@ -0,0 +1,145 @@
{
lib,
stdenv,
rustPlatform,
bun,
pkg-config,
dbus ? null,
openssl,
glib ? null,
gtk3 ? null,
libsoup_3 ? null,
webkitgtk_4_1 ? null,
librsvg ? null,
libappindicator-gtk3 ? null,
cargo,
rustc,
makeBinaryWrapper,
nodejs,
jq,
}:
args:
let
scripts = args.scripts;
mkModules =
attrs:
args.mkNodeModules (
attrs
// {
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
}
);
in
rustPlatform.buildRustPackage rec {
pname = "opencode-desktop";
version = args.version;
src = args.src;
# We need to set the root for cargo, but we also need access to the whole repo.
postUnpack = ''
# Update sourceRoot to point to the tauri app
sourceRoot+=/packages/desktop/src-tauri
'';
cargoLock = {
lockFile = ../packages/desktop/src-tauri/Cargo.lock;
allowBuiltinFetchGit = true;
};
node_modules = mkModules {
version = version;
src = src;
};
nativeBuildInputs = [
pkg-config
bun
makeBinaryWrapper
cargo
rustc
nodejs
jq
];
buildInputs = [
openssl
]
++ lib.optionals stdenv.isLinux [
dbus
glib
gtk3
libsoup_3
webkitgtk_4_1
librsvg
libappindicator-gtk3
];
preBuild = ''
# Restore node_modules
pushd ../../..
# Copy node_modules from the fixed-output derivation
# We use cp -r --no-preserve=mode to ensure we can write to them if needed,
# though we usually just read.
cp -r ${node_modules}/node_modules .
cp -r ${node_modules}/packages .
# Ensure node_modules is writable so patchShebangs can update script headers
chmod -R u+w node_modules
# Ensure workspace packages are writable for tsgo incremental outputs (.tsbuildinfo)
chmod -R u+w packages
# Patch shebangs so scripts can run
patchShebangs node_modules
# Copy sidecar
mkdir -p packages/desktop/src-tauri/sidecars
targetTriple=${stdenv.hostPlatform.rust.rustcTarget}
cp ${args.opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-$targetTriple
# Merge prod config into tauri.conf.json
if ! jq -s '.[0] * .[1]' \
packages/desktop/src-tauri/tauri.conf.json \
packages/desktop/src-tauri/tauri.prod.conf.json \
> packages/desktop/src-tauri/tauri.conf.json.tmp; then
echo "Error: failed to merge tauri.conf.json with tauri.prod.conf.json" >&2
exit 1
fi
mv packages/desktop/src-tauri/tauri.conf.json.tmp packages/desktop/src-tauri/tauri.conf.json
# Build the frontend
cd packages/desktop
# The 'build' script runs 'bun run typecheck && vite build'.
bun run build
popd
'';
# Tauri bundles the assets during the rust build phase (which happens after preBuild).
# It looks for them in the location specified in tauri.conf.json.
postInstall = lib.optionalString stdenv.isLinux ''
# Wrap the binary to ensure it finds the libraries
wrapProgram $out/bin/opencode-desktop \
--prefix LD_LIBRARY_PATH : ${
lib.makeLibraryPath [
gtk3
webkitgtk_4_1
librsvg
glib
libsoup_3
]
}
'';
meta = with lib; {
description = "OpenCode Desktop App";
homepage = "https://opencode.ai";
license = licenses.mit;
maintainers = with maintainers; [ ];
mainProgram = "opencode-desktop";
platforms = platforms.linux ++ platforms.darwin;
};
}

View File

@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw="
"nodeModules": "sha256-rNGq0yjL5ZHYVg+zyV4nFPug4gqhKhyOnfebaufyd34="
}

View File

@@ -60,7 +60,12 @@ const result = await Bun.build({
compile: {
target,
outfile: "opencode",
execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"],
autoloadBunfig: false,
autoloadDotenv: false,
//@ts-ignore (bun types aren't up to date)
autoloadTsconfig: true,
autoloadPackageJson: true,
execArgv: ["--user-agent=opencode/" + version, "--use-system-ca", "--"],
windows: {},
},
})

View File

@@ -1,6 +1,6 @@
## Debugging
- To test the opencode app, use the playwrite mcp server, the app is already
- To test the opencode app, use the playwright MCP server, the app is already
running at http://localhost:3000
- NEVER try to restart the app, or the server process, EVER.

View File

@@ -1,8 +1,8 @@
## Usage
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
Dependencies for these templates are managed with [pnpm](https://pnpm.io) using `pnpm up -Lri`.
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
This is the reason you see a `pnpm-lock.yaml`. That said, any package manager will work. This file can safely be removed once you clone a template.
```bash
$ npm install # or pnpm install or yarn install

View File

@@ -47,7 +47,7 @@
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div>
<div id="root" class="flex flex-col h-dvh"></div>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.224",
"version": "1.1.6",
"description": "",
"type": "module",
"exports": {

View File

@@ -242,6 +242,53 @@ describe("SerializeAddon", () => {
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
})
test("serialized output should restore after Terminal.reset()", async () => {
const { term, addon } = createTerminal()
const content = [
"\x1b[1;32m\x1b[0m \x1b[34mcd\x1b[0m /some/path",
"\x1b[1;32m\x1b[0m \x1b[34mls\x1b[0m -la",
"total 42",
].join("\r\n")
await writeAndWait(term, content)
const serialized = addon.serialize()
const { term: term2 } = createTerminal()
terminals.push(term2)
term2.reset()
await writeAndWait(term2, serialized)
expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
})
test("alternate buffer should round-trip without garbage", async () => {
const { term, addon } = createTerminal(20, 5)
await writeAndWait(term, "normal\r\n")
await writeAndWait(term, "\x1b[?1049h\x1b[HALT")
expect(term.buffer.active.type).toBe("alternate")
const serialized = addon.serialize()
const { term: term2 } = createTerminal(20, 5)
terminals.push(term2)
await writeAndWait(term2, serialized)
expect(term2.buffer.active.type).toBe("alternate")
const line = term2.buffer.active.getLine(0)
expect(line?.translateToString(true)).toBe("ALT")
// Ensure a cell beyond content isn't garbage
const cellCode = line?.getCell(10)?.getCode()
expect(cellCode === 0 || cellCode === 32).toBe(true)
})
test("serialized output written to new terminal should match original colors", async () => {
const { term, addon } = createTerminal(40, 5)

View File

@@ -157,23 +157,6 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
abstract class BaseSerializeHandler {
constructor(protected readonly _buffer: IBuffer) {}
private _isRealContent(codepoint: number): boolean {
if (codepoint === 0) return false
if (codepoint >= 0xf000) return false
return true
}
private _findLastContentColumn(line: IBufferLine): number {
let lastContent = -1
for (let col = 0; col < line.length; col++) {
const cell = line.getCell(col)
if (cell && this._isRealContent(cell.getCode())) {
lastContent = col
}
}
return lastContent + 1
}
public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
let oldCell = this._buffer.getNullCell()
@@ -182,14 +165,14 @@ abstract class BaseSerializeHandler {
const startColumn = range.start.x
const endColumn = range.end.x
this._beforeSerialize(endRow - startRow, startRow, endRow)
this._beforeSerialize(endRow - startRow + 1, startRow, endRow)
for (let row = startRow; row <= endRow; row++) {
const line = this._buffer.getLine(row)
if (line) {
const startLineColumn = row === range.start.y ? startColumn : 0
const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line)
const endLineColumn = Math.min(maxColumn, line.length)
const endLineColumn = Math.min(endColumn, line.length)
for (let col = startLineColumn; col < endLineColumn; col++) {
const c = line.getCell(col)
if (!c) {
@@ -243,6 +226,13 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _beforeSerialize(rows: number, start: number, _end: number): void {
this._allRows = new Array<string>(rows)
this._allRowSeparators = new Array<string>(rows)
this._rowIndex = 0
this._currentRow = ""
this._nullCellCount = 0
this._cursorStyle = this._buffer.getNullCell()
this._lastContentCursorRow = start
this._lastCursorRow = start
this._firstRow = start
@@ -251,6 +241,11 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = ""
if (this._nullCellCount > 0) {
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
if (!isLastRow) {
const nextLine = this._buffer.getLine(row + 1)
@@ -388,7 +383,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
const codepoint = cell.getCode()
const isGarbage = codepoint >= 0xf000
const isInvalidCodepoint = codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)
const isGarbage = isInvalidCodepoint || (codepoint >= 0xf000 && cell.getWidth() === 1)
const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
@@ -397,7 +393,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (styleChanged) {
if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C`
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
@@ -417,7 +413,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._nullCellCount += cell.getWidth()
} else {
if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C`
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}

View File

@@ -1,5 +1,5 @@
import "@/index.css"
import { ErrorBoundary, Show, type ParentProps } from "solid-js"
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -20,16 +20,20 @@ import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { Logo } from "@opencode-ai/ui/logo"
import Layout from "@/pages/layout"
import Home from "@/pages/home"
import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { ErrorPage } from "./pages/error"
import { iife } from "@opencode-ai/util/iife"
import { Suspense } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full flex items-center justify-center text-text-weak">Loading...</div>
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
}
}
@@ -45,6 +49,25 @@ const defaultServerUrl = iife(() => {
return window.location.origin
})
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
)
}
function ServerKey(props: ParentProps) {
const server = useServer()
return (
@@ -54,62 +77,56 @@ function ServerKey(props: ParentProps) {
)
}
export function App() {
export function AppInterface() {
return (
<MetaProvider>
<Font />
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<ServerProvider defaultUrl={defaultServerUrl}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(props) => (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Session />
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ServerProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
<ServerProvider defaultUrl={defaultServerUrl}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(props) => (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)}
>
<Route
path="/"
component={() => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)}
/>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ServerProvider>
)
}

View File

@@ -27,6 +27,7 @@ export function DialogSelectFile() {
const value = file.tab(path)
tabs().open(value)
file.load(path)
layout.review.open()
}
dialog.close()
}}

File diff suppressed because it is too large Load Diff

View File

@@ -305,13 +305,19 @@ export function SessionContextTab(props: SessionContextTabProps) {
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const restoreScroll = () => {
const restoreScroll = (retries = 0) => {
const el = scroll
if (!el) return
const s = props.view()?.scroll("context")
if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}

View File

@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { base64Encode } from "@opencode-ai/util/encode"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -20,6 +20,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { same } from "@/utils/same"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
@@ -31,10 +32,17 @@ export function SessionHeader() {
const dialog = useDialog()
const sync = useSync()
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
const parentSession = createMemo(() => {
const current = currentSession()
if (!current?.parentID) return undefined
return sync.data.session.find((s) => s.id === current.parentID)
})
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const branch = createMemo(() => sync.data.vcs?.branch)
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
@@ -42,11 +50,13 @@ export function SessionHeader() {
function navigateToSession(session: Session | undefined) {
if (!session) return
// Only navigate if we're actually changing to a different session
if (session.id === params.id) return
navigate(`/${params.dir}/session/${session.id}`)
}
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex">
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
@@ -59,13 +69,9 @@ export function SessionHeader() {
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={sync.directory}
label={(x) => {
const name = getFilename(x)
const b = x === sync.directory ? branch() : undefined
return b ? `${name}:${b}` : name
}}
options={worktrees()}
current={sync.project?.worktree ?? projectDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
@@ -80,18 +86,56 @@ export function SessionHeader() {
</Select>
<div class="text-text-weaker">/</div>
</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
<Show
when={parentSession()}
fallback={
<>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</>
}
>
<div class="flex items-center gap-2 min-w-0">
<Select
options={sessions()}
current={parentSession()}
placeholder="Back to parent session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={(session) => {
// Only navigate if selecting a different session than current parent
const currentParent = parentSession()
if (session && currentParent && session.id !== currentParent.id) {
navigateToSession(session)
}
}}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
<div class="text-text-weaker">/</div>
<div class="flex items-center gap-1.5 min-w-0">
<Tooltip value="Back to parent session">
<button
type="button"
class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0"
onClick={() => navigateToSession(parentSession())}
>
<Icon name="arrow-left" size="small" class="text-icon-base" />
</button>
</Tooltip>
</div>
</div>
</Show>
</div>
<Show when={currentSession()}>
<Show when={currentSession() && !parentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind>
@@ -191,7 +235,7 @@ export function SessionHeader() {
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: sync.directory })
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
@@ -200,8 +244,13 @@ export function SessionHeader() {
}
return shareURL
},
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
</Show>
)
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
})}
</Popover>
</Show>

View File

@@ -1,22 +1,76 @@
import { Show } from "solid-js"
import { Show, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Select } from "@opencode-ai/ui/select"
export function NewSessionView() {
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
interface NewSessionViewProps {
worktree: string
onWorktreeChange: (value: string) => void
}
export function NewSessionView(props: NewSessionViewProps) {
const sync = useSync()
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
const current = createMemo(() => {
const selection = props.worktree
if (options().includes(selection)) return selection
return MAIN_WORKTREE
})
const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
const isWorktree = createMemo(() => {
const project = sync.project
if (!project) return false
return sync.data.path.directory !== project.worktree
})
const label = (value: string) => {
if (value === MAIN_WORKTREE) {
if (isWorktree()) return "Main branch"
const branch = sync.data.vcs?.branch
if (branch) return `Main branch (${branch})`
return "Main branch"
}
if (value === CREATE_WORKTREE) return "Create new worktree"
return getFilename(value)
}
return (
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
<div
class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"
style={{ "padding-bottom": "calc(var(--prompt-height, 11.25rem) + 64px)" }}
>
<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" />
<div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
{getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span>
</div>
</div>
<div class="flex justify-center items-center gap-1">
<Icon name="branch" size="small" />
<Select
options={options()}
current={current()}
value={(x) => x}
label={label}
onSelect={(value) => {
props.onWorktreeChange(value ?? MAIN_WORKTREE)
}}
size="normal"
variant="ghost"
class="text-12-medium"
/>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-center gap-3">

View File

@@ -39,6 +39,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
</Tooltip>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
>
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</Tabs.Trigger>

View File

@@ -1,9 +1,9 @@
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
@@ -16,6 +16,7 @@ type TerminalColors = {
background: string
foreground: string
cursor: string
selectionBackground: string
}
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
@@ -23,11 +24,13 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
background: "#fcfcfc",
foreground: "#211e1e",
cursor: "#211e1e",
selectionBackground: withAlpha("#211e1e", 0.2),
},
dark: {
background: "#191515",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
selectionBackground: withAlpha("#d4d4d4", 0.25),
},
}
@@ -36,12 +39,14 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket
let term: Term
let ws: WebSocket | undefined
let term: Term | undefined
let ghostty: Ghostty
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
let reconnect: number | undefined
let disposed = false
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
@@ -51,12 +56,16 @@ export const Terminal = (props: TerminalProps) => {
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-base"] ?? fallback.foreground
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha)
return {
background,
foreground: text,
cursor: text,
selectionBackground,
}
}
@@ -71,27 +80,11 @@ export const Terminal = (props: TerminalProps) => {
setOption("theme", colors)
})
const focusTerminal = () => term?.focus()
const copySelection = () => {
if (!term || !term.hasSelection()) return false
const selection = term.getSelection()
if (!selection) return false
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
if (!document.body) return false
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
document.body.removeChild(textarea)
return copied
const focusTerminal = () => {
const t = term
if (!t) return
t.focus()
setTimeout(() => t.textarea?.focus(), 0)
}
const handlePointerDown = () => {
const activeElement = document.activeElement
@@ -102,10 +95,15 @@ export const Terminal = (props: TerminalProps) => {
}
onMount(async () => {
ghostty = await Ghostty.load()
const mod = await import("ghostty-web")
ghostty = await mod.Ghostty.load()
ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
term = new Term({
const socket = new WebSocket(
sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
)
ws = socket
const t = new mod.Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "IBM Plex Mono, monospace",
@@ -114,50 +112,83 @@ export const Terminal = (props: TerminalProps) => {
scrollback: 10_000,
ghostty,
})
term.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (key === "c") {
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
if ((macCopy || linuxCopy) && copySelection()) {
event.preventDefault()
return true
}
term = t
const copy = () => {
const selection = t.getSelection()
if (!selection) return false
const body = document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
// allow for ctrl-` to toggle terminal in parent
if (event.ctrlKey && key === "`") {
event.preventDefault()
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
return false
}
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
copy()
return true
}
if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
if (!t.hasSelection()) return true
copy()
return true
}
// allow for ctrl-` to toggle terminal in parent
if (event.ctrlKey && key === "`") {
return true
}
return false
})
fitAddon = new FitAddon()
fitAddon = new mod.FitAddon()
serializeAddon = new SerializeAddon()
term.loadAddon(serializeAddon)
term.loadAddon(fitAddon)
t.loadAddon(serializeAddon)
t.loadAddon(fitAddon)
term.open(container)
t.open(container)
container.addEventListener("pointerdown", handlePointerDown)
focusTerminal()
if (local.pty.buffer) {
if (local.pty.rows && local.pty.cols) {
term.resize(local.pty.cols, local.pty.rows)
t.resize(local.pty.cols, local.pty.rows)
}
term.reset()
term.write(local.pty.buffer)
if (local.pty.scrollY) {
term.scrollToLine(local.pty.scrollY)
}
fitAddon.fit()
t.write(local.pty.buffer, () => {
if (local.pty.scrollY) {
t.scrollToLine(local.pty.scrollY)
}
fitAddon.fit()
})
}
fitAddon.observeResize()
handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize)
term.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) {
t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty
.update({
ptyID: local.pty.id,
@@ -169,39 +200,39 @@ export const Terminal = (props: TerminalProps) => {
.catch(() => {})
}
})
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data)
t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
})
term.onKey((key) => {
t.onKey((key) => {
if (key.key == "Enter") {
props.onSubmit?.()
}
})
// term.onScroll((ydisp) => {
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
ws.addEventListener("open", () => {
socket.addEventListener("open", () => {
console.log("WebSocket connected")
sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: term.cols,
rows: term.rows,
cols: t.cols,
rows: t.rows,
},
})
.catch(() => {})
})
ws.addEventListener("message", (event) => {
term.write(event.data)
socket.addEventListener("message", (event) => {
t.write(event.data)
})
ws.addEventListener("error", (error) => {
socket.addEventListener("error", (error) => {
console.error("WebSocket error:", error)
props.onConnectError?.(error)
})
ws.addEventListener("close", () => {
socket.addEventListener("close", () => {
console.log("WebSocket disconnected")
})
})
@@ -211,18 +242,21 @@ export const Terminal = (props: TerminalProps) => {
window.removeEventListener("resize", handleResize)
}
container.removeEventListener("pointerdown", handlePointerDown)
if (serializeAddon && props.onCleanup) {
const t = term
if (serializeAddon && props.onCleanup && t) {
const buffer = serializeAddon.serialize()
props.onCleanup({
...local.pty,
buffer,
rows: term.rows,
cols: term.cols,
scrollY: term.getViewportY(),
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),
})
}
ws?.close()
term?.dispose()
t?.dispose()
})
return (

View File

@@ -177,8 +177,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const dialog = useDialog()
const options = createMemo(() => {
const all = registrations().flatMap((x) => x())
const seen = new Set<string>()
const all: CommandOption[] = []
for (const reg of registrations()) {
for (const opt of reg()) {
if (seen.has(opt.id)) continue
seen.add(opt.id)
all.push(opt)
}
}
const suggested = all.filter((x) => x.suggested && !x.disabled)
return [
...suggested.map((x) => ({
...x,

View File

@@ -1,4 +1,4 @@
import { createMemo, onCleanup } from "solid-js"
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2"
@@ -7,7 +7,7 @@ import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
export type FileSelection = {
startLine: number
@@ -134,10 +134,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {},
})
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const [view, setView, _, ready] = persisted(
viewKey(),
Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
createStore<{
file: Record<string, FileViewState>
}>({
@@ -145,6 +145,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}),
)
const MAX_VIEW_FILES = 500
const viewMeta = { pruned: false }
const pruneView = (keep?: string) => {
const keys = Object.keys(view.file)
if (keys.length <= MAX_VIEW_FILES) return
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
if (drop.length === 0) return
setView(
produce((draft) => {
for (const key of drop) {
delete draft.file[key]
}
}),
)
}
createEffect(() => {
if (!ready()) return
if (viewMeta.pruned) return
viewMeta.pruned = true
pruneView()
})
function ensure(path: string) {
if (!path) return
if (store.file[path]) return
@@ -233,6 +259,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scrollTop: top,
}
})
pruneView(path)
}
const setScrollLeft = (input: string, left: number) => {
@@ -244,6 +271,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scrollLeft: left,
}
})
pruneView(path)
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
@@ -256,6 +284,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
selectedLines: next,
}
})
pruneView(path)
}
onCleanup(() => stop())

View File

@@ -1,7 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { batch, onCleanup } from "solid-js"
import { usePlatform } from "./platform"
import { useServer } from "./server"
@@ -19,14 +19,79 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
let queue: Array<Queued | undefined> = []
const coalesced = new Map<string, number>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
const events = queue
queue = []
coalesced.clear()
if (events.length === 0) return
last = Date.now()
batch(() => {
for (const event of events) {
if (!event) continue
emitter.emit(event.directory, event.payload)
}
})
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
}
const stop = () => {
flush()
}
void (async () => {
const events = await eventSdk.global.event()
let yielded = Date.now()
for await (const event of events.stream) {
emitter.emit(event.directory ?? "global", event.payload)
}
})().catch(() => undefined)
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = undefined
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
onCleanup(() => abort.abort())
if (Date.now() - yielded < 8) continue
yielded = Date.now()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
})()
.finally(stop)
.catch(() => undefined)
onCleanup(() => {
abort.abort()
stop()
})
const platform = usePlatform()
const sdk = createOpencodeClient({

View File

@@ -23,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error"
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
@@ -212,7 +212,7 @@ function createGlobalSync() {
.catch((e) => setGlobalStore("error", e))
}
globalSDK.event.listen((e) => {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -404,6 +404,7 @@ function createGlobalSync() {
}
}
})
onCleanup(unsub)
async function bootstrap() {
const health = await globalSDK.client.global

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
import { createScrollPersistence } from "./layout-scroll"
describe("createScrollPersistence", () => {
test("debounces persisted scroll writes", async () => {
const key = "layout-scroll.test"
const data = new Map<string, string>()
const writes: string[] = []
const stats = { flushes: 0 }
const storage = {
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => {
data.set(k, v)
if (k === key) writes.push(v)
},
removeItem: (k: string) => {
data.delete(k)
},
} as SyncStorage
await new Promise<void>((resolve, reject) => {
createRoot((dispose) => {
const [raw, setRaw] = createStore({
sessionView: {} as Record<string, { scroll: Record<string, { x: number; y: number }> }>,
})
const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
const scroll = createScrollPersistence({
debounceMs: 30,
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
onFlush: (sessionKey, next) => {
stats.flushes += 1
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
return
}
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
},
})
const run = async () => {
await new Promise((r) => setTimeout(r, 0))
writes.length = 0
for (const i of Array.from({ length: 100 }, (_, n) => n)) {
scroll.setScroll("session", "review", { x: 0, y: i })
}
await new Promise((r) => setTimeout(r, 120))
expect(stats.flushes).toBeGreaterThanOrEqual(1)
expect(writes.length).toBeGreaterThanOrEqual(1)
expect(writes.length).toBeLessThanOrEqual(2)
}
void run()
.then(resolve)
.catch(reject)
.finally(() => {
scroll.dispose()
dispose()
})
})
})
})
})

View File

@@ -0,0 +1,118 @@
import { createStore, produce } from "solid-js/store"
export type SessionScroll = {
x: number
y: number
}
type ScrollMap = Record<string, SessionScroll>
type Options = {
debounceMs?: number
getSnapshot: (sessionKey: string) => ScrollMap | undefined
onFlush: (sessionKey: string, scroll: ScrollMap) => void
}
export function createScrollPersistence(opts: Options) {
const wait = opts.debounceMs ?? 200
const [cache, setCache] = createStore<Record<string, ScrollMap>>({})
const dirty = new Set<string>()
const timers = new Map<string, ReturnType<typeof setTimeout>>()
function clone(input?: ScrollMap) {
const out: ScrollMap = {}
if (!input) return out
for (const key of Object.keys(input)) {
const pos = input[key]
if (!pos) continue
out[key] = { x: pos.x, y: pos.y }
}
return out
}
function seed(sessionKey: string) {
if (cache[sessionKey]) return
setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
}
function scroll(sessionKey: string, tab: string) {
seed(sessionKey)
return cache[sessionKey]?.[tab] ?? opts.getSnapshot(sessionKey)?.[tab]
}
function schedule(sessionKey: string) {
const prev = timers.get(sessionKey)
if (prev) clearTimeout(prev)
timers.set(
sessionKey,
setTimeout(() => flush(sessionKey), wait),
)
}
function setScroll(sessionKey: string, tab: string, pos: SessionScroll) {
seed(sessionKey)
const prev = cache[sessionKey]?.[tab]
if (prev?.x === pos.x && prev?.y === pos.y) return
setCache(sessionKey, tab, { x: pos.x, y: pos.y })
dirty.add(sessionKey)
schedule(sessionKey)
}
function flush(sessionKey: string) {
const timer = timers.get(sessionKey)
if (timer) clearTimeout(timer)
timers.delete(sessionKey)
if (!dirty.has(sessionKey)) return
dirty.delete(sessionKey)
opts.onFlush(sessionKey, clone(cache[sessionKey]))
}
function flushAll() {
const keys = Array.from(dirty)
if (keys.length === 0) return
for (const key of keys) {
flush(key)
}
}
function drop(keys: string[]) {
if (keys.length === 0) return
for (const key of keys) {
const timer = timers.get(key)
if (timer) clearTimeout(timer)
timers.delete(key)
dirty.delete(key)
}
setCache(
produce((draft) => {
for (const key of keys) {
delete draft[key]
}
}),
)
}
function dispose() {
drop(Array.from(timers.keys()))
}
return {
cache,
drop,
flush,
flushAll,
scroll,
seed,
setScroll,
dispose,
}
}

View File

@@ -1,11 +1,13 @@
import { createStore, produce } from "solid-js/store"
import { batch, createMemo, onMount } from "solid-js"
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { Project } from "@opencode-ai/sdk/v2"
import { persisted } from "@/utils/persist"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -23,23 +25,11 @@ export function getAvatarColors(key?: string) {
}
}
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type SessionTabs = {
active?: string
all: string[]
}
type SessionScroll = {
x: number
y: number
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
@@ -56,7 +46,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync()
const server = useServer()
const [store, setStore, _, ready] = persisted(
"layout.v6",
Persist.global("layout", ["layout.v6"]),
createStore({
sidebar: {
opened: false,
@@ -81,6 +71,121 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}),
)
const MAX_SESSION_KEYS = 50
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
const SESSION_STATE_KEYS = [
{ key: "prompt", legacy: "prompt", version: "v2" },
{ key: "terminal", legacy: "terminal", version: "v1" },
{ key: "file-view", legacy: "file", version: "v1" },
] as const
const dropSessionState = (keys: string[]) => {
for (const key of keys) {
const parts = key.split("/")
const dir = parts[0]
const session = parts[1]
if (!dir) continue
for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
void removePersisted(target)
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey })
}
}
}
function prune(keep?: string) {
if (!keep) return
const keys = new Set<string>()
for (const key of Object.keys(store.sessionView)) keys.add(key)
for (const key of Object.keys(store.sessionTabs)) keys.add(key)
if (keys.size <= MAX_SESSION_KEYS) return
const score = (key: string) => {
if (key === keep) return Number.MAX_SAFE_INTEGER
return used.get(key) ?? 0
}
const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
const drop = ordered.slice(MAX_SESSION_KEYS)
if (drop.length === 0) return
setStore(
produce((draft) => {
for (const key of drop) {
delete draft.sessionView[key]
delete draft.sessionTabs[key]
}
}),
)
scroll.drop(drop)
dropSessionState(drop)
for (const key of drop) {
used.delete(key)
}
}
function touch(sessionKey: string) {
meta.active = sessionKey
used.set(sessionKey, Date.now())
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
prune(sessionKey)
}
const scroll = createScrollPersistence({
debounceMs: 250,
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
onFlush: (sessionKey, next) => {
const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
prune(keep)
return
}
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
prune(keep)
},
})
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
const active = meta.active
if (!active) return
meta.pruned = true
prune(active)
})
onMount(() => {
const flush = () => batch(() => scroll.flushAll())
const handleVisibility = () => {
if (document.visibilityState !== "hidden") return
flush()
}
window.addEventListener("pagehide", flush)
document.addEventListener("visibilitychange", handleVisibility)
onCleanup(() => {
window.removeEventListener("pagehide", flush)
document.removeEventListener("visibilitychange", handleVisibility)
scroll.dispose()
})
})
const usedColors = new Set<AvatarColorKey>()
function pickAvailableColor(): AvatarColorKey {
@@ -90,11 +195,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function enrich(project: { worktree: string; expanded: boolean }) {
const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
const [childStore] = globalSync.child(project.worktree)
const projectID = childStore.project
const metadata = projectID
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
return [
{
...project,
...(metadata ?? {}),
...project,
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
},
]
@@ -111,6 +220,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return project
}
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of globalSync.data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
}
}
return map
})
createEffect(() => {
const map = roots()
if (map.size === 0) return
const projects = server.projects.list()
const seen = new Set(projects.map((project) => project.worktree))
batch(() => {
for (const project of projects) {
const root = map.get(project.worktree)
if (!root) continue
server.projects.close(project.worktree)
if (!seen.has(root)) {
server.projects.open(root)
seen.add(root)
}
if (project.expanded) server.projects.expand(root)
}
})
})
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize))
@@ -127,11 +271,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: {
list,
open(directory: string) {
if (server.projects.list().find((x) => x.worktree === directory)) {
return
}
globalSync.project.loadSessions(directory)
server.projects.open(directory)
const root = roots().get(directory) ?? directory
if (server.projects.list().find((x) => x.worktree === root)) return
globalSync.project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {
server.projects.close(directory)
@@ -221,21 +364,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
view(sessionKey: string) {
touch(sessionKey)
scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
return {
scroll(tab: string) {
return s().scroll?.[tab]
return scroll.scroll(sessionKey, tab)
},
setScroll(tab: string, pos: SessionScroll) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
return
}
const prev = current.scroll?.[tab]
if (prev?.x === pos.x && prev?.y === pos.y) return
setStore("sessionView", sessionKey, "scroll", tab, pos)
scroll.setScroll(sessionKey, tab, pos)
},
review: {
open: createMemo(() => s().reviewOpen),
@@ -253,6 +390,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
tabs(sessionKey: string) {
touch(sessionKey)
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return {
tabs,

View File

@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createMemo } from "solid-js"
import { batch, createMemo, onCleanup } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -8,7 +8,7 @@ import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@opencode-ai/ui/toast"
export type LocalFile = FileNode &
@@ -111,7 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const model = (() => {
const [store, setStore, _, modelReady] = persisted(
"model.v1",
Persist.global("model", ["model.v1"]),
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
@@ -160,6 +160,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
),
)
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
const userVisibilityMap = createMemo(() => {
const map = new Map<string, "show" | "hide">()
for (const item of store.user) {
map.set(`${item.providerID}:${item.modelID}`, item.visibility)
}
return map
})
const list = createMemo(() =>
available().map((m) => ({
...m,
@@ -264,12 +274,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
},
visible(model: ModelKey) {
const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID)
return (
user?.visibility !== "hide" &&
(latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) ||
user?.visibility === "show")
)
const key = `${model.providerID}:${model.modelID}`
const visibility = userVisibilityMap().get(key)
return visibility !== "hide" && (latestSet().has(key) || visibility === "show")
},
setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide")
@@ -458,7 +465,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const searchFilesAndDirectories = (query: string) =>
sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
sdk.event.listen((e) => {
const unsub = sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "file.watcher.updated":
@@ -468,6 +475,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
break
}
})
onCleanup(unsub)
return {
node: async (path: string) => {

View File

@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store"
import { createEffect, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync"
@@ -9,7 +10,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
type NotificationBase = {
directory?: string
@@ -30,6 +31,16 @@ type ErrorNotification = NotificationBase & {
export type Notification = TurnCompleteNotification | ErrorNotification
const MAX_NOTIFICATIONS = 500
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
function pruneNotifications(list: Notification[]) {
const cutoff = Date.now() - NOTIFICATION_TTL_MS
const pruned = list.filter((n) => n.time >= cutoff)
if (pruned.length <= MAX_NOTIFICATIONS) return pruned
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
}
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
@@ -48,13 +59,26 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
"notification.v1",
Persist.global("notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
globalSDK.event.listen((e) => {
const meta = { pruned: false }
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
setStore("list", pruneNotifications(store.list))
})
const append = (notification: Notification) => {
setStore("list", (list) => pruneNotifications([...list, notification]))
}
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
const base = {
@@ -72,7 +96,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
try {
idlePlayer?.play()
} catch {}
setStore("list", store.list.length, {
append({
...base,
type: "turn-complete",
session: sessionID,
@@ -91,7 +115,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
errorPlayer?.play()
} catch {}
const error = "error" in event.properties ? event.properties.error : undefined
setStore("list", store.list.length, {
append({
...base,
type: "error",
session: sessionID ?? "global",
@@ -104,6 +128,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
}
}
})
onCleanup(unsub)
return {
ready,

View File

@@ -1,12 +1,12 @@
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
import { base64Decode } from "@opencode-ai/util/encode"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
type PermissionRespondFn = (input: {
sessionID: string
@@ -19,6 +19,32 @@ function shouldAutoAccept(perm: PermissionRequest) {
return perm.permission === "edit"
}
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
if (typeof rule !== "object") return false
if (Array.isArray(rule)) return false
for (const action of Object.values(rule)) {
if (action !== "allow") return true
}
return false
}
function hasAutoAcceptPermissionConfig(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
if (Array.isArray(permission)) return false
const config = permission as Record<string, unknown>
if (isNonAllowRule(config.edit)) return true
if (isNonAllowRule(config.write)) return true
return false
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
init: () => {
@@ -27,13 +53,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => {
if (!params.dir || !base64Decode(params.dir)) return false
const [store] = globalSync.child(base64Decode(params.dir))
return store.config.permission !== undefined
const directory = params.dir ? base64Decode(params.dir) : undefined
if (!directory) return false
const [store] = globalSync.child(directory)
return hasAutoAcceptPermissionConfig(store.config.permission)
})
const [store, setStore, _, ready] = persisted(
"permission.v3",
Persist.global("permission", ["permission.v3"]),
createStore({
autoAcceptEdits: {} as Record<string, boolean>,
}),
@@ -58,8 +85,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
}
function isAutoAccepting(sessionID: string) {
return store.autoAcceptEdits[sessionID] ?? false
function acceptKey(sessionID: string, directory?: string) {
if (!directory) return sessionID
return `${base64Encode(directory)}/${sessionID}`
}
function isAutoAccepting(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
}
const unsubscribe = globalSDK.event.listen((e) => {
@@ -67,7 +100,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (event?.type !== "permission.asked") return
const perm = event.properties
if (!isAutoAccepting(perm.sessionID)) return
if (!isAutoAccepting(perm.sessionID, e.name)) return
if (!shouldAutoAccept(perm)) return
respondOnce(perm, e.name)
@@ -75,7 +108,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
onCleanup(unsubscribe)
function enable(sessionID: string, directory: string) {
setStore("autoAcceptEdits", sessionID, true)
const key = acceptKey(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAcceptEdits[key] = true
delete draft.autoAcceptEdits[sessionID]
}),
)
globalSDK.client.permission
.list({ directory })
@@ -90,31 +129,37 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
.catch(() => undefined)
}
function disable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, false)
function disable(sessionID: string, directory?: string) {
const key = directory ? acceptKey(sessionID, directory) : undefined
setStore(
produce((draft) => {
if (key) delete draft.autoAcceptEdits[key]
delete draft.autoAcceptEdits[sessionID]
}),
)
}
return {
ready,
respond,
autoResponds(permission: PermissionRequest) {
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
autoResponds(permission: PermissionRequest, directory?: string) {
return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
},
isAutoAccepting,
toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID)) {
disable(sessionID)
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID)) return
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string) {
disable(sessionID)
disableAutoAccept(sessionID: string, directory?: string) {
disable(sessionID, directory)
},
permissionsEnabled,
}

View File

@@ -3,7 +3,7 @@ import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
export type Platform = {
/** Platform discriminator */
platform: "web" | "tauri"
platform: "web" | "desktop"
/** App version */
version?: string

View File

@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
interface PartBase {
content: string
@@ -103,10 +103,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
name: "Prompt",
init: () => {
const params = useParams()
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
const [store, setStore, _, ready] = persisted(
name(),
Persist.scoped(params.dir!, params.id, "prompt", [legacy()]),
createStore<{
prompt: Prompt
cursor?: number

View File

@@ -1,6 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
@@ -20,9 +21,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
globalSDK.event.on(props.directory, async (event) => {
const unsub = globalSDK.event.on(props.directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
},

View File

@@ -1,9 +1,9 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
type StoredProject = { worktree: string; expanded: boolean }
@@ -11,8 +11,7 @@ export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
if (!trimmed) return
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
const cleaned = withProtocol.replace(/\/+$/, "")
return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
return withProtocol.replace(/\/+$/, "")
}
export function serverDisplayName(url: string) {
@@ -36,7 +35,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
"server.v3",
Persist.global("server", ["server.v3"]),
createStore({
list: [] as string[],
projects: {} as Record<string, StoredProject[]>,
@@ -92,27 +91,49 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!active())
const [healthy, { refetch }] = createResource(
() => active() || undefined,
async (url) => {
if (!url) return
const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
.then((x) => x.data?.healthy === true)
.catch(() => false)
},
)
const check = (url: string) => {
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
.then((x) => x.data?.healthy === true)
.catch(() => false)
}
createEffect(() => {
if (!active()) return
const interval = setInterval(() => refetch(), 10_000)
onCleanup(() => clearInterval(interval))
const url = active()
if (!url) return
setHealthy(undefined)
let alive = true
let busy = false
const run = () => {
if (busy) return
busy = true
void check(url)
.then((next) => {
if (!alive) return
setHealthy(next)
})
.finally(() => {
busy = false
})
}
run()
const interval = setInterval(run, 10_000)
onCleanup(() => {
alive = false
clearInterval(interval)
})
})
const origin = createMemo(() => projectsKey(active()))

View File

@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
export type LocalPTY = {
id: string
@@ -19,10 +19,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
init: () => {
const sdk = useSDK()
const params = useParams()
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
const [store, setStore, _, ready] = persisted(
name(),
Persist.scoped(params.dir!, params.id, "terminal", [legacy()]),
createStore<{
active?: string
all: LocalPTY[]

View File

@@ -1,6 +1,6 @@
// @refresh reload
import { render } from "solid-js/web"
import { App } from "@/app"
import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import pkg from "../package.json"
@@ -55,7 +55,9 @@ const platform: Platform = {
render(
() => (
<PlatformProvider value={platform}>
<App />
<AppBaseProviders>
<AppInterface />
</AppBaseProviders>
</PlatformProvider>
),
root!,

View File

@@ -1,2 +1,2 @@
export { PlatformProvider, type Platform } from "./context/platform"
export { App } from "./app"
export { AppBaseProviders, AppInterface } from "./app"

View File

@@ -1,5 +1,5 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
import { useNavigate, useParams } from "@solidjs/router"
import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
@@ -10,6 +10,7 @@ import { iife } from "@opencode-ai/util/iife"
export default function Layout(props: ParentProps) {
const params = useParams()
const navigate = useNavigate()
const directory = createMemo(() => {
return base64Decode(params.dir!)
})
@@ -26,8 +27,17 @@ export default function Layout(props: ParentProps) {
response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input)
const navigateToSession = (sessionID: string) => {
navigate(`/${params.dir}/session/${sessionID}`)
}
return (
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
<DataProvider
data={sync.data}
directory={directory()}
onPermissionRespond={respond}
onNavigateToSession={navigateToSession}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)

View File

@@ -53,8 +53,8 @@ export default function Home() {
}
return (
<div class="mx-auto mt-55">
<Logo class="w-xl opacity-12" />
<div class="mx-auto mt-55 w-full md:w-auto px-4">
<Logo class="md:w-xl opacity-12" />
<Button
size="large"
variant="ghost"

View File

@@ -170,11 +170,11 @@ export default function Layout(props: ParentProps) {
if (e.details?.type !== "permission.asked") return
const directory = e.name
const perm = e.details.properties
if (permission.autoResponds(perm)) return
if (permission.autoResponds(perm, directory)) return
const sessionKey = `${directory}:${perm.sessionID}`
const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === perm.sessionID)
const sessionKey = `${directory}:${perm.sessionID}`
const sessionTitle = session?.title ?? "New session"
const projectName = getFilename(directory)
@@ -266,24 +266,30 @@ export default function Layout(props: ParentProps) {
}
}
function projectSessions(directory: string) {
if (!directory) return []
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
return (sessions ?? []).filter((s) => !s.parentID)
const currentProject = createMemo(() => {
const directory = params.dir ? base64Decode(params.dir) : undefined
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
function projectSessions(project: LocalProject | undefined) {
if (!project) return []
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const stores = dirs.map((dir) => globalSync.child(dir)[0])
const sessions = stores
.flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
.toSorted(sortSessions)
return sessions.filter((s) => !s.parentID)
}
const currentSessions = createMemo(() => {
if (!params.dir) return []
const directory = base64Decode(params.dir)
return projectSessions(directory)
})
const currentSessions = createMemo(() => projectSessions(currentProject()))
function navigateSessionByOffset(offset: number) {
const projects = layout.projects.list()
if (projects.length === 0) return
const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
const project = currentProject()
const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1
if (projectIndex === -1) {
const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
@@ -312,14 +318,14 @@ export default function Layout(props: ParentProps) {
const nextProject = projects[nextProjectIndex]
if (!nextProject) return
const nextProjectSessions = projectSessions(nextProject.worktree)
const nextProjectSessions = projectSessions(nextProject)
if (nextProjectSessions.length === 0) {
navigateToProject(nextProject.worktree)
return
}
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
navigateToSession(targetSession)
queueMicrotask(() => scrollToSession(targetSession.id))
}
@@ -465,7 +471,7 @@ export default function Layout(props: ParentProps) {
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session?.id}`)
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
layout.mobileSidebar.hide()
}
@@ -514,7 +520,8 @@ export default function Layout(props: ParentProps) {
const id = params.id
setStore("lastSession", directory, id)
notification.session.markViewed(id)
untrack(() => layout.projects.expand(directory))
const project = currentProject()
untrack(() => layout.projects.expand(project?.worktree ?? directory))
requestAnimationFrame(() => scrollToSession(id))
})
@@ -644,13 +651,13 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const [sessionStore] = globalSync.child(props.session.directory)
const hasPermissions = createMemo(() => {
const store = globalSync.child(props.project.worktree)[0]
const permissions = store.permission?.[props.session.id] ?? []
const permissions = sessionStore.permission?.[props.session.id] ?? []
if (permissions.length > 0) return true
const childSessions = store.session.filter((s) => s.parentID === props.session.id)
const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id)
for (const child of childSessions) {
const childPermissions = store.permission?.[child.id] ?? []
const childPermissions = sessionStore.permission?.[child.id] ?? []
if (childPermissions.length > 0) return true
}
return false
@@ -658,21 +665,20 @@ export default function Layout(props: ParentProps) {
const isWorking = createMemo(() => {
if (props.session.id === params.id) return false
if (hasPermissions()) return false
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
const status = sessionStore.session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry"
})
return (
<>
<div
data-session-id={props.session.id}
class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
class="group/session relative w-full rounded-md cursor-default transition-colors
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
style={{ "padding-left": "16px" }}
>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
<A
href={`${props.slug}/session/${props.session.id}`}
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
>
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
<span
@@ -740,10 +746,17 @@ export default function Layout(props: ParentProps) {
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.project.worktree)
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
const slug = createMemo(() => base64Encode(props.project.worktree))
const defaultWorktree = createMemo(() => base64Encode(props.project.worktree))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const [store, setProjectStore] = globalSync.child(props.project.worktree)
const sessions = createMemo(() => store.session.toSorted(sortSessions))
const stores = createMemo(() =>
[props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]),
)
const sessions = createMemo(() =>
stores()
.flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
.toSorted(sortSessions),
)
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
const loadMoreSessions = async () => {
@@ -753,6 +766,10 @@ export default function Layout(props: ParentProps) {
const isExpanded = createMemo(() =>
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
)
const isActive = createMemo(() => {
const current = params.dir ? base64Decode(params.dir) : ""
return props.project.worktree === current || props.project.sandboxes?.includes(current)
})
const handleOpenChange = (open: boolean) => {
if (props.mobile) {
if (open) mobileProjects.expand(props.project.worktree)
@@ -771,7 +788,10 @@ export default function Layout(props: ParentProps) {
<Button
as={"div"}
variant="ghost"
class="group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg"
classList={{
"group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true,
"bg-surface-raised-base-hover": isActive() && !isExpanded(),
}}
>
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
<ProjectAvatar
@@ -799,7 +819,7 @@ export default function Layout(props: ParentProps) {
</DropdownMenu.Portal>
</DropdownMenu>
<TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
<IconButton as={A} href={`${defaultWorktree()}/session`} icon="plus-small" variant="ghost" />
</TooltipKeybind>
</div>
</Button>
@@ -807,7 +827,12 @@ export default function Layout(props: ParentProps) {
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
<For each={rootSessions()}>
{(session) => (
<SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} />
<SessionItem
session={session}
slug={base64Encode(session.directory)}
project={props.project}
mobile={props.mobile}
/>
)}
</For>
<Show when={rootSessions().length === 0}>
@@ -819,7 +844,7 @@ export default function Layout(props: ParentProps) {
<div class="flex-1 min-w-0">
<Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
<A
href={`${slug()}/session`}
href={`${defaultWorktree()}/session`}
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
>
<div class="flex items-center self-stretch gap-6 justify-between">
@@ -875,76 +900,85 @@ export default function Layout(props: ParentProps) {
const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
return (
<>
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
<div class="flex flex-col self-stretch h-full items-center justify-between overflow-hidden min-h-0">
<div class="flex flex-col items-start self-stretch gap-4 min-h-0">
<Show when={!sidebarProps.mobile}>
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region>
<Mark class="shrink-0" />
</A>
</Show>
<Show when={!sidebarProps.mobile}>
<TooltipKeybind
class="shrink-0"
placement="right"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
inactive={expanded()}
>
<Button
variant="ghost"
size="large"
class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
onClick={layout.sidebar.toggle}
>
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
size="small"
class="group-hover/sidebar-toggle:hidden"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
size="small"
class="hidden group-hover/sidebar-toggle:inline-block"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
size="small"
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
<Show when={layout.sidebar.opened()}>
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
Toggle sidebar
</div>
</Show>
</Button>
</TooltipKeybind>
</Show>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!sidebarProps.mobile) scrollContainerRef = el
classList={{
"border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0": true,
"justify-start": expanded(),
}}
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
>
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
<For each={layout.projects.list()}>
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
</For>
</SortableProvider>
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2 w-full" data-tauri-drag-region>
<Mark class="shrink-0" />
</A>
</div>
<DragOverlay>
<ProjectDragOverlay />
</DragOverlay>
</DragDropProvider>
</Show>
<div class="flex flex-col items-start self-stretch gap-4 px-2 overflow-hidden min-h-0">
<Show when={!sidebarProps.mobile}>
<TooltipKeybind
class="shrink-0"
placement="right"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
inactive={expanded()}
>
<Button
variant="ghost"
size="large"
class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
onClick={layout.sidebar.toggle}
>
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
size="small"
class="group-hover/sidebar-toggle:hidden"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
size="small"
class="hidden group-hover/sidebar-toggle:inline-block"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
size="small"
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
<Show when={layout.sidebar.opened()}>
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
Toggle sidebar
</div>
</Show>
</Button>
</TooltipKeybind>
</Show>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!sidebarProps.mobile) scrollContainerRef = el
}}
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
>
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
<For each={layout.projects.list()}>
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
</For>
</SortableProvider>
</div>
<DragOverlay>
<ProjectDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch>
@@ -1017,7 +1051,7 @@ export default function Layout(props: ParentProps) {
</Button>
</Tooltip>
</div>
</>
</div>
)
}
@@ -1065,12 +1099,21 @@ export default function Layout(props: ParentProps) {
/>
<div
classList={{
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pb-5 transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
<div class="border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0">
<A
href="/"
class="shrink-0 h-8 flex items-center justify-start px-2 w-full"
onClick={() => layout.mobileSidebar.hide()}
>
<Mark class="shrink-0" />
</A>
</div>
<SidebarContent mobile />
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, createRenderEffect, batch } from "solid-js"
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file"
@@ -24,7 +25,7 @@ import { useSync } from "@/context/sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal"
import { checksum } from "@opencode-ai/util/encode"
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
@@ -47,12 +48,8 @@ import {
SortableTerminalTab,
NewSessionView,
} from "@/components/session"
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
import { usePlatform } from "@/context/platform"
import { same } from "@/utils/same"
type DiffStyle = "unified" | "split"
@@ -61,6 +58,7 @@ interface SessionReviewTabProps {
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void
classes?: {
root?: string
header?: string
@@ -73,13 +71,19 @@ function SessionReviewTab(props: SessionReviewTabProps) {
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const restoreScroll = () => {
const restoreScroll = (retries = 0) => {
const el = scroll
if (!el) return
const s = props.view().scroll("review")
if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
@@ -134,6 +138,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
diffs={props.diffs()}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
onViewFile={props.onViewFile}
/>
)
}
@@ -147,6 +152,7 @@ export default function Page() {
const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
const platform = usePlatform()
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
@@ -218,20 +224,12 @@ export default function Page() {
return sync.data.message[id] !== undefined
})
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
const visibleUserMessages = createMemo(() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
}, emptyUserMessages)
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
createEffect(
@@ -249,13 +247,18 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
userInteracted: false,
stepsExpanded: true,
mobileStepsExpanded: {} as Record<string, boolean>,
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "review",
ignoreScrollSpy: false,
initialScrollDone: !params.id,
newSessionWorktree: "main",
promptHeight: 0,
})
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
const project = sync.project
if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
return "main"
})
const activeMessage = createMemo(() => {
@@ -290,6 +293,8 @@ export default function Page() {
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let scroller: HTMLDivElement | undefined
createEffect(() => {
if (!params.id) return
@@ -316,47 +321,24 @@ export default function Page() {
),
)
createEffect(
on(
() => params.id,
(id) => {
const status = sync.data.session_status[id ?? ""] ?? idle
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", status.type !== "idle")
})
},
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
createEffect(
on(
() => status().type,
(type) => {
if (type !== "idle") return
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", false)
})
() => params.id,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
},
{ defer: true },
),
)
const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
createRenderEffect((prev) => {
const isWorking = working()
if (!prev && isWorking) {
setStore("stepsExpanded", true)
}
if (prev && !isWorking && !store.userInteracted) {
setStore("stepsExpanded", false)
}
return isWorking
}, working())
createEffect(() => {
const id = lastUserMessage()?.id
if (!id) return
setStore("expanded", id, status().type !== "idle")
})
command.register(() => [
{
@@ -405,12 +387,16 @@ export default function Page() {
{
id: "steps.toggle",
title: "Toggle steps",
description: "Show or hide the steps",
description: "Show or hide steps for the current message",
category: "View",
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
onSelect: () => setStore("stepsExpanded", (x) => !x),
onSelect: () => {
const msg = activeMessage()
if (!msg) return
setStore("expanded", msg.id, (open: boolean | undefined) => !open)
},
},
{
id: "message.previous",
@@ -481,7 +467,10 @@ export default function Page() {
},
{
id: "permissions.autoaccept",
title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? "Stop auto-accepting edits"
: "Auto-accept edits",
category: "Permissions",
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
@@ -490,8 +479,10 @@ export default function Page() {
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
description: permission.isAutoAccepting(sessionID)
title: permission.isAutoAccepting(sessionID, sdk.directory)
? "Auto-accepting edits"
: "Stopped auto-accepting edits",
description: permission.isAutoAccepting(sessionID, sdk.directory)
? "Edit and write permissions will be automatically approved"
: "Edit and write permissions will require approval",
})
@@ -518,7 +509,7 @@ export default function Page() {
// Restore the prompt from the reverted message
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts)
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
// Navigate to the message before the reverted one (which will be the new last visible message)
@@ -680,204 +671,97 @@ export default function Page() {
const isWorking = createMemo(() => status().type !== "idle")
const autoScroll = createAutoScroll({
working: isWorking,
onUserInteracted: () => setStore("userInteracted", true),
})
let scrollContainer: HTMLDivElement | undefined
let initialScrollFrame: number | undefined
let initialScrollTarget: string | undefined
const cancelInitialScroll = () => {
if (initialScrollFrame === undefined) return
cancelAnimationFrame(initialScrollFrame)
initialScrollFrame = undefined
}
const ensureInitialScroll = () => {
cancelInitialScroll()
initialScrollFrame = requestAnimationFrame(() => {
initialScrollFrame = undefined
if (!params.id) {
initialScrollTarget = undefined
setStore("initialScrollDone", true)
return
}
const msgs = visibleUserMessages()
if (msgs.length === 0) {
if (!messagesReady()) {
ensureInitialScroll()
return
}
initialScrollTarget = undefined
setStore("initialScrollDone", true)
return
}
const last = msgs[msgs.length - 1]
const el = messageRefs.get(last.id)
if (!el || !scrollContainer) {
ensureInitialScroll()
return
}
scrollToMessage(last, "auto")
initialScrollTarget = last.id
setStore("initialScrollDone", true)
})
}
const setScrollRef = (el: HTMLDivElement | undefined) => {
scrollContainer = el
autoScroll.scrollRef(el)
}
const messageRefs = new Map<string, HTMLDivElement>()
let scrollTimer: number | undefined
createEffect(() => {
const msgs = visibleUserMessages()
if (msgs.length === 0) {
messageRefs.clear()
return
}
const ids = new Set(msgs.map((m) => m.id))
for (const id of messageRefs.keys()) {
if (ids.has(id)) continue
messageRefs.delete(id)
}
})
let scrollSpyIndex = 0
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setStore("ignoreScrollSpy", true)
setActiveMessage(message)
const msgs = visibleUserMessages()
const idx = msgs.findIndex((m) => m.id === message.id)
if (idx >= 0) scrollSpyIndex = idx
const el = messageRefs.get(message.id)
if (el) {
el.scrollIntoView({ behavior, block: "start" })
}
if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
scrollTimer = window.setTimeout(() => setStore("ignoreScrollSpy", false), 1000)
}
let scrollSpyFrame: number | undefined
let scrollSpyTarget: HTMLDivElement | undefined
const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el)
}
createResizeObserver(
() => promptDock,
({ height }) => {
const next = Math.ceil(height)
if (next === store.promptHeight) return
const el = scroller
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
setStore("promptHeight", next)
if (stick && el) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
})
}
},
)
const updateHash = (id: string) => {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setActiveMessage(message)
const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" })
updateHash(message.id)
}
const getActiveMessageId = (container: HTMLDivElement) => {
const cutoff = container.scrollTop + 100
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
let id: string | undefined
for (const node of nodes) {
const next = node.dataset.messageId
if (!next) continue
if (node.offsetTop > cutoff) break
id = next
}
return id
}
const scheduleScrollSpy = (container: HTMLDivElement) => {
if (store.ignoreScrollSpy) return
scrollSpyTarget = container
if (scrollSpyFrame !== undefined) return
scrollSpyFrame = requestAnimationFrame(() => {
scrollSpyFrame = undefined
const target = scrollSpyTarget
scrollSpyTarget = undefined
if (!target) return
if (store.ignoreScrollSpy) return
const msgs = visibleUserMessages()
const scrollTop = target.scrollTop
const threshold = 100
const cutoff = scrollTop + threshold
const id = getActiveMessageId(target)
if (!id) return
if (id === store.messageId) return
if (msgs.length === 0) return
if (scrollSpyIndex >= msgs.length) scrollSpyIndex = msgs.length - 1
if (scrollSpyIndex < 0) scrollSpyIndex = 0
while (scrollSpyIndex + 1 < msgs.length) {
const next = msgs[scrollSpyIndex + 1]
if (!next) break
const el = messageRefs.get(next.id)
if (!el) break
if (el.offsetTop <= cutoff) {
scrollSpyIndex += 1
continue
}
break
}
while (scrollSpyIndex > 0) {
const cur = msgs[scrollSpyIndex]
if (!cur) break
const el = messageRefs.get(cur.id)
if (!el) break
if (el.offsetTop > cutoff) {
scrollSpyIndex -= 1
continue
}
break
}
const msg = msgs[scrollSpyIndex]
if (!msg) return
if (msg.id === activeMessage()?.id) return
setActiveMessage(msg)
setStore("messageId", id)
})
}
createEffect(
on(
() => params.id,
(id) => {
cancelInitialScroll()
if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
scrollTimer = undefined
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
scrollSpyFrame = undefined
scrollSpyTarget = undefined
messageRefs.clear()
scrollSpyIndex = 0
initialScrollTarget = undefined
setStore("initialScrollDone", !id)
},
{ defer: true },
),
)
createEffect(() => {
const msgs = visibleUserMessages()
const target = msgs.at(-1)?.id
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
if (!params.id) {
setStore("initialScrollDone", true)
initialScrollTarget = undefined
return
}
if (!ready) {
setStore("initialScrollDone", false)
ensureInitialScroll()
return
}
if (!store.initialScrollDone) {
ensureInitialScroll()
return
}
if (!initialScrollTarget && target) {
setStore("initialScrollDone", false)
ensureInitialScroll()
}
})
createEffect(() => {
const msgs = visibleUserMessages()
if (msgs.length === 0) return
requestAnimationFrame(() => {
if (!scrollContainer) return
if (!isDesktop()) return
// Manually trigger spy once to set initial active message based on scroll position
scheduleScrollSpy(scrollContainer)
const id = window.location.hash.slice(1)
const hashTarget = id ? document.getElementById(id) : undefined
if (hashTarget) {
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
return
}
autoScroll.forceScrollToBottom()
})
})
@@ -887,8 +771,6 @@ export default function Page() {
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
cancelInitialScroll()
if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
})
@@ -926,7 +808,10 @@ export default function Page() {
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 md:flex-none py-6 md:py-3": true,
}}
style={{ width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%" }}
style={{
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
}}
>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
@@ -940,8 +825,13 @@ export default function Page() {
diffs={diffs}
view={view}
diffStyle="unified"
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
classes={{
root: "pb-32",
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4",
container: "px-4",
}}
@@ -969,13 +859,10 @@ export default function Page() {
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
classList={{
"opacity-0 pointer-events-none": !store.initialScrollDone,
}}
>
<div
ref={autoScroll.contentRef}
class="flex flex-col gap-45 items-start justify-start pb-32 md:pb-40 transition-[margin]"
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
classList={{
"mt-0.5": !showTabs(),
"mt-0": showTabs(),
@@ -984,16 +871,24 @@ export default function Page() {
<For each={visibleUserMessages()}>
{(message) => (
<div
ref={(el) => messageRefs.set(message.id, el)}
class="min-w-0 w-full max-w-full last:min-h-[80vh]"
id={anchor(message.id)}
data-message-id={message.id}
classList={{
"min-w-0 w-full max-w-full": true,
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
platform.platform !== "desktop",
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
platform.platform === "desktop",
}}
>
<SessionTurn
sessionID={params.id!}
messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)}
stepsExpanded={store.expanded[message.id] ?? false}
onStepsExpandedToggle={() =>
setStore("expanded", message.id, (open: boolean | undefined) => !open)
}
classes={{
root: "min-w-0 w-full relative",
content:
@@ -1017,13 +912,32 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<NewSessionView />
<NewSessionView
worktree={newSessionWorktree()}
onWorktreeChange={(value) => {
if (value === "create") {
setStore("newSessionWorktree", value)
return
}
setStore("newSessionWorktree", "main")
const target = value === "main" ? sync.project?.worktree : value
if (!target) return
if (target === sync.data.path.directory) return
layout.projects.open(target)
navigate(`/${base64Encode(target)}/session`)
}}
/>
</Match>
</Switch>
</div>
{/* Prompt input */}
<div class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none">
<div
ref={(el) => (promptDock = el)}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
>
<div
classList={{
"w-full md:px-6 pointer-events-auto": true,
@@ -1034,6 +948,8 @@ export default function Page() {
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
/>
</div>
</div>
@@ -1089,6 +1005,7 @@ export default function Page() {
</Tooltip>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
@@ -1123,6 +1040,11 @@ export default function Page() {
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/>
</div>
</Tabs.Content>
@@ -1153,6 +1075,35 @@ export default function Page() {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => checksum(contents()))
const isImage = createMemo(() => {
const c = state()?.content
return (
c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
)
})
const isSvg = createMemo(() => {
const c = state()?.content
return c?.mimeType === "image/svg+xml"
})
const svgContent = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return base64Decode(c.content)
return c.content
})
const svgPreviewUrl = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
})
const imageDataUrl = createMemo(() => {
if (!isImage()) return
const c = state()?.content
return `data:${c?.mimeType};base64,${c?.content}`
})
const selectedLines = createMemo(() => {
const p = path()
if (!p) return null
@@ -1170,13 +1121,19 @@ export default function Page() {
return `L${sel.startLine}-${sel.endLine}`
})
const restoreScroll = () => {
const restoreScroll = (retries = 0) => {
const el = scroll
if (!el) return
const s = view()?.scroll(tab)
if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
@@ -1221,6 +1178,17 @@ export default function Page() {
),
)
createEffect(
on(
() => tabs().active() === tab,
(active) => {
if (!active) return
if (!state()?.loaded) return
requestAnimationFrame(restoreScroll)
},
),
)
onCleanup(() => {
if (scrollFrame === undefined) return
cancelAnimationFrame(scrollFrame)
@@ -1255,6 +1223,37 @@ export default function Page() {
)}
</Show>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img src={imageDataUrl()} alt={path()} class="max-w-full" />
</div>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
<Dynamic
component={codeComponent}
file={{
name: path() ?? "",
contents: svgContent() ?? "",
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
}}
overflow="scroll"
class="select-text"
/>
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded}>
<Dynamic
component={codeComponent}

View File

@@ -1,17 +1,235 @@
import { usePlatform } from "@/context/platform"
import { makePersisted } from "@solid-primitives/storage"
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
const platform = usePlatform()
const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
type PersistTarget = {
storage?: string
key: string
legacy?: string[]
migrate?: (value: unknown) => unknown
}
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
function snapshot(value: unknown) {
return JSON.parse(JSON.stringify(value)) as unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function merge(defaults: unknown, value: unknown): unknown {
if (value === undefined) return defaults
if (value === null) return value
if (Array.isArray(defaults)) {
if (Array.isArray(value)) return value
return defaults
}
if (isRecord(defaults)) {
if (!isRecord(value)) return defaults
const result: Record<string, unknown> = { ...defaults }
for (const key of Object.keys(value)) {
if (key in defaults) {
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
} else {
result[key] = (value as Record<string, unknown>)[key]
}
}
return result
}
return value
}
function parse(value: string) {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
function workspaceStorage(dir: string) {
const head = dir.slice(0, 12) || "workspace"
const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat`
}
function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:`
return {
getItem: (key) => localStorage.getItem(base + key),
setItem: (key, value) => localStorage.setItem(base + key, value),
removeItem: (key) => localStorage.removeItem(base + key),
}
}
export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
},
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
},
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
},
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
if (session) return Persist.session(dir, session, key, legacy)
return Persist.workspace(dir, key, legacy)
},
}
export function removePersisted(target: { storage?: string; key: string }) {
const platform = usePlatform()
const isDesktop = platform.platform === "desktop" && !!platform.storage
if (isDesktop) {
return platform.storage?.(target.storage)?.removeItem(target.key)
}
if (!target.storage) {
localStorage.removeItem(target.key)
return
}
localStorageWithPrefix(target.storage).removeItem(target.key)
}
export function persisted<T>(
target: string | PersistTarget,
store: [Store<T>, SetStoreFunction<T>],
): PersistedWithReady<T> {
const platform = usePlatform()
const config: PersistTarget = typeof target === "string" ? { key: target } : target
const defaults = snapshot(store[0])
const legacy = config.legacy ?? []
const isDesktop = platform.platform === "desktop" && !!platform.storage
const currentStorage = (() => {
if (isDesktop) return platform.storage?.(config.storage)
if (!config.storage) return localStorage
return localStorageWithPrefix(config.storage)
})()
const legacyStorage = (() => {
if (!isDesktop) return localStorage
if (!config.storage) return platform.storage?.()
return platform.storage?.(LEGACY_STORAGE)
})()
const storage = (() => {
if (!isDesktop) {
const current = currentStorage as SyncStorage
const legacyStore = legacyStorage as SyncStorage
const api: SyncStorage = {
getItem: (key) => {
const raw = current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) current.setItem(key, next)
return next
}
for (const legacyKey of legacy) {
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
current.setItem(key, legacyRaw)
legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) current.setItem(key, next)
return next
}
return null
},
setItem: (key, value) => {
current.setItem(key, value)
},
removeItem: (key) => {
current.removeItem(key)
},
}
return api
}
const current = currentStorage as AsyncStorage
const legacyStore = legacyStorage as AsyncStorage | undefined
const api: AsyncStorage = {
getItem: async (key) => {
const raw = await current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) await current.setItem(key, next)
return next
}
if (!legacyStore) return null
for (const legacyKey of legacy) {
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
await current.setItem(key, legacyRaw)
await legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) await current.setItem(key, next)
return next
}
return null
},
setItem: async (key, value) => {
await current.setItem(key, value)
},
removeItem: async (key) => {
await current.removeItem(key)
},
}
return api
})()
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
// Create a resource that resolves when the store is initialized
// This integrates with Suspense and provides a ready signal
const isAsync = init instanceof Promise
const [ready] = createResource(
() => init,

View File

@@ -1,47 +1,202 @@
import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
import type { Prompt, FileAttachmentPart } from "@/context/prompt"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
type Inline =
| {
type: "file"
start: number
end: number
value: string
path: string
selection?: {
startLine: number
endLine: number
startChar: number
endChar: number
}
}
| {
type: "agent"
start: number
end: number
value: string
name: string
}
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
const queryIndex = url.indexOf("?")
if (queryIndex === -1) return undefined
const params = new URLSearchParams(url.slice(queryIndex + 1))
const startLine = Number(params.get("start"))
const endLine = Number(params.get("end"))
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
return {
startLine,
endLine,
startChar: 0,
endChar: 0,
}
}
function textPartValue(parts: Part[]) {
const candidates = parts
.filter((part): part is TextPart => part.type === "text")
.filter((part) => !part.synthetic && !part.ignored)
return candidates.reduce((best: TextPart | undefined, part) => {
if (!best) return part
if (part.text.length > best.text.length) return part
return best
}, undefined)
}
/**
* Extract prompt content from message parts for restoring into the prompt input.
* This is used by undo to restore the original user prompt.
*/
export function extractPromptFromParts(parts: Part[]): Prompt {
const result: Prompt = []
let position = 0
export function extractPromptFromParts(parts: Part[], opts?: { directory?: string }): Prompt {
const textPart = textPartValue(parts)
const text = textPart?.text ?? ""
const directory = opts?.directory
const toRelative = (path: string) => {
if (!directory) return path
const prefix = directory.endsWith("/") ? directory : directory + "/"
if (path.startsWith(prefix)) return path.slice(prefix.length)
if (path.startsWith(directory)) {
const next = path.slice(directory.length)
if (next.startsWith("/")) return next.slice(1)
return next
}
return path
}
const inline: Inline[] = []
const images: ImageAttachmentPart[] = []
for (const part of parts) {
if (part.type === "text") {
const textPart = part as TextPart
if (!textPart.synthetic && textPart.text) {
result.push({
type: "text",
content: textPart.text,
start: position,
end: position + textPart.text.length,
})
position += textPart.text.length
}
} else if (part.type === "file") {
if (part.type === "file") {
const filePart = part as FilePart
if (filePart.source?.type === "file") {
const path = filePart.source.path
const content = "@" + path
const attachment: FileAttachmentPart = {
type: "file",
path,
content,
start: position,
end: position + content.length,
const sourceText = filePart.source?.text
if (sourceText) {
const value = sourceText.value
const start = sourceText.start
const end = sourceText.end
let path = value
if (value.startsWith("@")) path = value.slice(1)
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
path = filePart.source.path
}
result.push(attachment)
position += content.length
inline.push({
type: "file",
start,
end,
value,
path: toRelative(path),
selection: selectionFromFileUrl(filePart.url),
})
continue
}
if (filePart.url.startsWith("data:")) {
images.push({
type: "image",
id: filePart.id,
filename: filePart.filename ?? "attachment",
mime: filePart.mime,
dataUrl: filePart.url,
})
}
}
if (part.type === "agent") {
const agentPart = part as MessageAgentPart
const source = agentPart.source
if (!source) continue
inline.push({
type: "agent",
start: source.start,
end: source.end,
value: source.value,
name: agentPart.name,
})
}
}
inline.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start
return a.end - b.end
})
const result: Prompt = []
let position = 0
let cursor = 0
const pushText = (content: string) => {
if (!content) return
result.push({
type: "text",
content,
start: position,
end: position + content.length,
})
position += content.length
}
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
const content = item.value
const attachment: FileAttachmentPart = {
type: "file",
path: item.path,
content,
start: position,
end: position + content.length,
selection: item.selection,
}
result.push(attachment)
position += content.length
}
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
const content = item.value
const mention: AgentPart = {
type: "agent",
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(mention)
position += content.length
}
for (const item of inline) {
if (item.start < 0 || item.end < item.start) continue
const expected = item.value
if (!expected) continue
const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
const start = mismatch ? text.indexOf(expected, cursor) : item.start
if (start === -1) continue
const end = mismatch ? start + expected.length : item.end
pushText(text.slice(cursor, start))
if (item.type === "file") pushFile(item)
if (item.type === "agent") pushAgent(item)
cursor = end
}
pushText(text.slice(cursor))
if (result.length === 0) {
result.push({ type: "text", content: "", start: 0, end: 0 })
}
return result
if (images.length === 0) return result
return [...result, ...images]
}

View File

@@ -0,0 +1,6 @@
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}

View File

@@ -1,7 +1,8 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.224",
"version": "1.1.6",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",

View File

@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "45K",
full: "45,000",
compact: "50K",
full: "50,000",
},
},

View File

@@ -2,6 +2,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -146,6 +147,242 @@ export async function POST(input: APIEvent) {
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
const customerID = body.data.object.customer as string
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
if (!customerID) throw new Error("Customer ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for customer")
await Database.use((tx) =>
tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
}),
)
}
if (body.type === "customer.subscription.created") {
const data = {
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
object: "event",
api_version: "2025-07-30.basil",
created: 1767766916,
data: {
object: {
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
object: "subscription",
application: null,
application_fee_percent: null,
automatic_tax: {
disabled_reason: null,
enabled: false,
liability: null,
},
billing_cycle_anchor: 1770445200,
billing_cycle_anchor_config: null,
billing_mode: {
flexible: {
proration_discounts: "included",
},
type: "flexible",
updated_at: 1770445200,
},
billing_thresholds: null,
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
cancellation_details: {
comment: null,
feedback: null,
reason: null,
},
collection_method: "charge_automatically",
created: 1770445200,
currency: "usd",
customer: "cus_TkKmZZvysJ2wej",
customer_account: null,
days_until_due: null,
default_payment_method: null,
default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
default_tax_rates: [],
description: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: "self",
},
},
items: {
object: "list",
data: [
{
id: "si_TkKnBKXFX76t0O",
object: "subscription_item",
billing_thresholds: null,
created: 1770445200,
current_period_end: 1772864400,
current_period_start: 1770445200,
discounts: [],
metadata: {},
plan: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "plan",
active: true,
amount: 20000,
amount_decimal: "20000",
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
interval: "month",
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: "licensed",
},
price: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "price",
active: true,
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
recurring: {
interval: "month",
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: "licensed",
},
tax_behavior: "unspecified",
tiers_mode: null,
transform_quantity: null,
type: "recurring",
unit_amount: 20000,
unit_amount_decimal: "20000",
},
quantity: 1,
subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
tax_rates: [],
},
],
has_more: false,
total_count: 1,
url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
},
latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
livemode: false,
metadata: {},
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: {
payment_method_options: null,
payment_method_types: null,
save_default_payment_method: "off",
},
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
plan: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "plan",
active: true,
amount: 20000,
amount_decimal: "20000",
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
interval: "month",
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: "licensed",
},
quantity: 1,
schedule: null,
start_date: 1770445200,
status: "active",
test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
transfer_data: null,
trial_end: null,
trial_settings: {
end_behavior: {
missing_payment_method: "create_invoice",
},
},
trial_start: null,
},
},
livemode: false,
pending_webhooks: 0,
request: {
id: "req_6YO9stvB155WJD",
idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
},
type: "customer.subscription.created",
}
}
if (body.type === "customer.subscription.deleted") {
const subscriptionID = body.data.object.id
if (!subscriptionID) throw new Error("Subscription ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
await Database.transaction(async (tx) => {
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
})
}
})()
.then((message) => {
return Response.json({ message: message ?? "done" }, { status: 200 })

View File

@@ -0,0 +1,8 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
}

View File

@@ -0,0 +1,58 @@
import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateSessionUrl({ returnUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: queryBillingInfo.key },
)
}, "sessionUrl")
export function BlackSection() {
const params = useParams()
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const [store, setStore] = createStore({
sessionRedirecting: false,
})
async function onClickSession() {
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
setStore("sessionRedirecting", true)
window.location.href = result.data
}
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Subscription</h2>
<div data-slot="title-row">
<p>You are subscribed to OpenCode Black for $200 per month.</p>
<button
data-color="primary"
disabled={sessionSubmission.pending || store.sessionRedirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage Subscription"}
</button>
</div>
</div>
</section>
)
}

View File

@@ -2,19 +2,23 @@ import { MonthlyLimitSection } from "./monthly-limit-section"
import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
export default function () {
const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id!))
const sessionInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={userInfo()?.isAdmin}>
<Show when={sessionInfo()?.isAdmin}>
<Show when={sessionInfo()?.isBeta && billingInfo()?.subscriptionID}>
<BlackSection />
</Show>
<BillingSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />

View File

@@ -45,6 +45,19 @@
text-decoration: line-through;
}
}
&[data-slot="payment-receipt"] {
span {
display: inline-block;
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
line-height: 1.5;
}
button {
font-size: var(--font-size-sm);
}
}
}
tbody tr {
@@ -61,13 +74,17 @@
}
th {
&:nth-child(2) /* Payment ID */ {
&:nth-child(2)
/* Payment ID */ {
display: none;
}
}
td {
&:nth-child(2) /* Payment ID */ {
&:nth-child(2)
/* Payment ID */ {
display: none;
}
}

View File

@@ -77,6 +77,7 @@ export function PaymentSection() {
<For each={payments()!}>
{(payment) => {
const date = new Date(payment.timeCreated)
const isCredit = !payment.paymentID
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
@@ -85,19 +86,24 @@ export function PaymentSection() {
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((payment.amount ?? 0) / 100000000).toFixed(2)}
{isCredit ? " (credit)" : ""}
</td>
<td data-slot="payment-receipt">
<button
onClick={async () => {
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
if (receiptUrl) {
window.open(receiptUrl, "_blank")
}
}}
data-slot="receipt-button"
>
View
</button>
{isCredit ? (
<span>-</span>
) : (
<button
onClick={async () => {
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
if (receiptUrl) {
window.open(receiptUrl, "_blank")
}
}}
data-slot="receipt-button"
>
View
</button>
)}
</td>
</tr>
)

View File

@@ -169,7 +169,9 @@ export function UsageSection() {
</Show>
</div>
</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
<td data-slot="usage-cost">
${usage.enrichment?.plan === "sub" ? "0.0000" : ((usage.cost ?? 0) / 100000000).toFixed(4)}
</td>
</tr>
)
}}

View File

@@ -1,6 +1,13 @@
export class AuthError extends Error {}
export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class SubscriptionError extends Error {
retryAfter?: number
constructor(message: string, retryAfter?: number) {
super(message)
this.retryAfter = retryAfter
}
}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class RateLimitError extends Error {}

View File

@@ -8,11 +8,20 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ZenData } from "@opencode-ai/console-core/model.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
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 {
AuthError,
CreditsError,
MonthlyLimitError,
SubscriptionError,
UserLimitError,
ModelError,
RateLimitError,
} from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
@@ -69,13 +78,13 @@ export async function handler(
const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
const isTrial = await trialLimiter?.isTrial()
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip)
await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId)
const stickyProvider = await stickyTracker?.get()
const authInfo = await authenticate(modelInfo)
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const authInfo = await authenticate(modelInfo)
const providerInfo = selectProvider(
zenData,
authInfo,
@@ -135,10 +144,10 @@ export async function handler(
})
}
return { providerInfo, authInfo, reqBody, res, startTimestamp }
return { providerInfo, reqBody, res, startTimestamp }
}
const { providerInfo, authInfo, reqBody, res, startTimestamp } = await retriableRequest()
const { providerInfo, reqBody, res, startTimestamp } = await retriableRequest()
// Store model request
dataDumper?.provideModel(providerInfo.storeModel)
@@ -172,8 +181,8 @@ export async function handler(
const tokensInfo = providerInfo.normalizeUsage(json.usage)
await trialLimiter?.track(tokensInfo)
await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo)
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo, costInfo)
return new Response(body, {
status: resStatus,
statusText: res.statusText,
@@ -206,8 +215,8 @@ export async function handler(
if (usage) {
const tokensInfo = providerInfo.normalizeUsage(usage)
await trialLimiter?.track(tokensInfo)
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo)
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo, costInfo)
}
c.close()
return
@@ -279,14 +288,19 @@ export async function handler(
{ status: 401 },
)
if (error instanceof RateLimitError)
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
const headers = new Headers()
if (error instanceof SubscriptionError && error.retryAfter) {
headers.set("retry-after", String(error.retryAfter))
}
return new Response(
JSON.stringify({
type: "error",
error: { type: error.constructor.name, message: error.message },
}),
{ status: 429 },
{ status: 429, headers },
)
}
return new Response(
JSON.stringify({
@@ -392,6 +406,7 @@ export async function handler(
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
reloadTrigger: BillingTable.reloadTrigger,
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
},
user: {
id: UserTable.id,
@@ -399,6 +414,13 @@ export async function handler(
monthlyUsage: UserTable.monthlyUsage,
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
},
subscription: {
timeSubscribed: UserTable.timeSubscribed,
subIntervalUsage: UserTable.subIntervalUsage,
subMonthlyUsage: UserTable.subMonthlyUsage,
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
},
provider: {
credentials: ProviderTable.credentials,
},
@@ -426,6 +448,7 @@ export async function handler(
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
isSubscription: data.subscription.timeSubscribed ? true : false,
})
return {
@@ -433,6 +456,7 @@ export async function handler(
workspaceID: data.workspaceID,
billing: data.billing,
user: data.user,
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
provider: data.provider,
isFree: FREE_WORKSPACES.includes(data.workspaceID),
isDisabled: !!data.timeDisabled,
@@ -445,6 +469,64 @@ export async function handler(
if (authInfo.isFree) return
if (modelInfo.allowAnonymous) return
// Validate subscription billing
if (authInfo.subscription) {
const black = BlackData.get()
const sub = authInfo.subscription
const now = new Date()
const formatRetryTime = (seconds: number) => {
const days = Math.floor(seconds / 86400)
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
const hours = Math.floor(seconds / 3600)
const minutes = Math.ceil((seconds % 3600) / 60)
if (hours >= 1) return `${hours}hr ${minutes}min`
return `${minutes}min`
}
// Check monthly limit (based on subscription billing cycle)
if (
sub.subMonthlyUsage &&
sub.timeSubMonthlyUsageUpdated &&
sub.subMonthlyUsage >= centsToMicroCents(black.monthlyLimit * 100)
) {
const subscribeDay = sub.timeSubscribed!.getUTCDate()
const cycleStart = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
subscribeDay,
),
)
const cycleEnd = new Date(Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay))
if (sub.timeSubMonthlyUsageUpdated >= cycleStart && sub.timeSubMonthlyUsageUpdated < cycleEnd) {
const retryAfter = Math.ceil((cycleEnd.getTime() - now.getTime()) / 1000)
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter,
)
}
}
// Check interval limit
const intervalMs = black.intervalLength * 3600 * 1000
if (sub.subIntervalUsage && sub.timeSubIntervalUsageUpdated) {
const currentInterval = Math.floor(now.getTime() / intervalMs)
const usageInterval = Math.floor(sub.timeSubIntervalUsageUpdated.getTime() / intervalMs)
if (currentInterval === usageInterval && sub.subIntervalUsage >= centsToMicroCents(black.intervalLimit * 100)) {
const nextInterval = (currentInterval + 1) * intervalMs
const retryAfter = Math.ceil((nextInterval - now.getTime()) / 1000)
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter,
)
}
}
return
}
// Validate pay as you go billing
const billing = authInfo.billing
if (!billing.paymentMethodID)
throw new CreditsError(
@@ -462,29 +544,25 @@ export async function handler(
billing.monthlyLimit &&
billing.monthlyUsage &&
billing.timeMonthlyUsageUpdated &&
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
) {
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new MonthlyLimitError(
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
)
}
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) &&
currentYear === billing.timeMonthlyUsageUpdated.getUTCFullYear() &&
currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
)
throw new MonthlyLimitError(
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
)
if (
authInfo.user.monthlyLimit &&
authInfo.user.monthlyUsage &&
authInfo.user.timeMonthlyUsageUpdated &&
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100)
) {
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new UserLimitError(
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
)
}
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) &&
currentYear === authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() &&
currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
)
throw new UserLimitError(
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
)
}
function validateModelSettings(authInfo: AuthInfo) {
@@ -559,61 +637,110 @@ export async function handler(
if (!authInfo) return
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
id: Identifier.create("usage"),
model: modelInfo.id,
provider: providerInfo.id,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
cost,
keyID: authInfo.apiKeyId,
})
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.use((db) =>
Promise.all([
db.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
id: Identifier.create("usage"),
model: modelInfo.id,
provider: providerInfo.id,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
cost,
keyID: authInfo.apiKeyId,
enrichment: authInfo.subscription ? { plan: "sub" } : undefined,
}),
db
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
...(authInfo.subscription
? (() => {
const now = new Date()
const subscribeDay = authInfo.subscription.timeSubscribed!.getUTCDate()
const cycleStart = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
subscribeDay,
),
)
const cycleEnd = new Date(
Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay),
)
return [
db
.update(UserTable)
.set({
subMonthlyUsage: sql`
CASE
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeSubMonthlyUsageUpdated: sql`now()`,
subIntervalUsage: sql`
CASE
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost}
ELSE ${cost}
END
`,
timeSubIntervalUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
]
})()
: [
db
.update(BillingTable)
.set({
balance: authInfo.isFree
? sql`${BillingTable.balance} - ${0}`
: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID))
await tx
.update(UserTable)
.set({
monthlyUsage: sql`
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
db
.update(UserTable)
.set({
monthlyUsage: sql`
CASE
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
]),
]),
)
return { costInMicroCents: cost }
}
async function reload(authInfo: AuthInfo) {
async function reload(authInfo: AuthInfo, costInfo: Awaited<ReturnType<typeof trackUsage>>) {
if (!authInfo) return
if (authInfo.isFree) return
if (authInfo.provider?.credentials) return
if (authInfo.subscription) return
if (!costInfo) return
const reloadTrigger = centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100)
if (authInfo.billing.balance - costInfo.costInMicroCents >= reloadTrigger) return
if (authInfo.billing.timeReloadLockedTill && authInfo.billing.timeReloadLockedTill > new Date()) return
const lock = await Database.use((tx) =>
tx
@@ -625,10 +752,7 @@ export async function handler(
and(
eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true),
lt(
BillingTable.balance,
centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
),
lt(BillingTable.balance, reloadTrigger),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
),
),

View File

@@ -1,28 +1,34 @@
import { Resource } from "@opencode-ai/console-resource"
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error"
import { logger } from "./logger"
export function createRateLimiter(model: string, limit: number | undefined, ip: string) {
export function createRateLimiter(limit: number | undefined, rawIp: string) {
if (!limit) return
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
const currKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now)}`
const prevKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now - 3_600_000)}`
let currRate: number
let prevRate: number
const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
return {
track: async () => {
await Resource.GatewayKv.put(currKey, currRate + 1, { expirationTtl: 3600 })
await Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values({ ip, interval: intervals[0], count: 1 })
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
)
},
check: async () => {
const values = await Resource.GatewayKv.get([currKey, prevKey])
const prevValue = values?.get(prevKey)
const currValue = values?.get(currKey)
prevRate = prevValue ? parseInt(prevValue) : 0
currRate = currValue ? parseInt(currValue) : 0
logger.debug(`rate limit ${model} prev/curr: ${prevRate}/${currRate}`)
if (prevRate + currRate >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
const rows = await Database.use((tx) =>
tx
.select({ count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))),
)
const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`)
if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
},
}
}

View File

@@ -0,0 +1 @@
CREATE INDEX `usage_time_created` ON `usage` (`workspace_id`,`time_created`);

View File

@@ -0,0 +1,6 @@
CREATE TABLE `ip_rate_limit` (
`ip` varchar(45) NOT NULL,
`interval` varchar(10) NOT NULL,
`count` int NOT NULL,
CONSTRAINT `ip_rate_limit_ip_interval_pk` PRIMARY KEY(`ip`,`interval`)
);

View File

@@ -0,0 +1,7 @@
ALTER TABLE `billing` ADD `subscription_id` varchar(28);--> statement-breakpoint
ALTER TABLE `usage` ADD `data` json;--> statement-breakpoint
ALTER TABLE `user` ADD `time_subscribed` timestamp(3);--> statement-breakpoint
ALTER TABLE `user` ADD `sub_recent_usage` bigint;--> statement-breakpoint
ALTER TABLE `user` ADD `sub_monthly_usage` bigint;--> statement-breakpoint
ALTER TABLE `user` ADD `sub_time_recent_usage_updated` timestamp(3);--> statement-breakpoint
ALTER TABLE `user` ADD `sub_time_monthly_usage_updated` timestamp(3);

View File

@@ -0,0 +1,2 @@
ALTER TABLE `user` RENAME COLUMN `sub_recent_usage` TO `sub_interval_usage`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `sub_time_recent_usage_updated` TO `sub_time_interval_usage_updated`;

View File

@@ -0,0 +1 @@
ALTER TABLE `usage` RENAME COLUMN `data` TO `enrichment`;

View File

@@ -0,0 +1 @@
ALTER TABLE `billing` ADD CONSTRAINT `global_subscription_id` UNIQUE(`subscription_id`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -281,6 +281,48 @@
"when": 1766946179892,
"tag": "0039_striped_forge",
"breakpoints": true
},
{
"idx": 40,
"version": "5",
"when": 1767584617316,
"tag": "0040_broken_gamora",
"breakpoints": true
},
{
"idx": 41,
"version": "5",
"when": 1767732559197,
"tag": "0041_odd_misty_knight",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1767744077346,
"tag": "0042_flat_nightmare",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1767752636118,
"tag": "0043_lame_calypso",
"breakpoints": true
},
{
"idx": 44,
"version": "5",
"when": 1767759322451,
"tag": "0044_tiny_captain_midlands",
"breakpoints": true
},
{
"idx": 45,
"version": "5",
"when": 1767765497502,
"tag": "0045_cuddly_diamondback",
"breakpoints": true
}
]
}

View File

@@ -1,9 +1,10 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.224",
"version": "1.1.6",
"private": true,
"type": "module",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -31,6 +32,9 @@
"promote-models-to-dev": "script/promote-models.ts dev",
"promote-models-to-prod": "script/promote-models.ts production",
"pull-models-from-dev": "script/pull-models.ts dev",
"update-black": "script/update-black.ts",
"promote-black-to-dev": "script/promote-black.ts dev",
"promote-black-to-prod": "script/promote-black.ts production",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {

View File

@@ -0,0 +1,20 @@
import { Billing } from "../src/billing.js"
// get input from command line
const workspaceID = process.argv[2]
const dollarAmount = process.argv[3]
if (!workspaceID || !dollarAmount) {
console.error("Usage: bun credit-workspace.ts <workspaceID> <dollarAmount>")
process.exit(1)
}
const amountInDollars = parseFloat(dollarAmount)
if (isNaN(amountInDollars) || amountInDollars <= 0) {
console.error("Error: dollarAmount must be a positive number")
process.exit(1)
}
await Billing.grantCredit(workspaceID, amountInDollars)
console.log(`Added payment of $${amountInDollars.toFixed(2)} to workspace ${workspaceID}`)

View File

@@ -1,26 +1,145 @@
import { Database, eq } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql"
import { Database, eq, sql, inArray } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
// get input from command line
const email = process.argv[2]
if (!email) {
console.error("Usage: bun lookup-user.ts <email>")
const identifier = process.argv[2]
if (!identifier) {
console.error("Usage: bun lookup-user.ts <email|workspaceID>")
process.exit(1)
}
const authData = await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.subject, email)))
if (authData.length === 0) {
console.error("User not found")
process.exit(1)
if (identifier.startsWith("wrk_")) {
await printWorkspace(identifier)
} else {
const authData = await Database.use(async (tx) =>
tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
)
if (authData.length === 0) {
console.error("Email not found")
process.exit(1)
}
if (authData.length > 1) console.warn("Multiple users found for email", identifier)
// Get all auth records for email
const accountID = authData[0].accountID
await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID)))
// Get all workspaces for this account
const users = await printTable("Workspaces", (tx) =>
tx
.select({
userID: UserTable.id,
workspaceID: UserTable.workspaceID,
workspaceName: WorkspaceTable.name,
role: UserTable.role,
})
.from(UserTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.where(eq(UserTable.accountID, accountID)),
)
// Get all payments for these workspaces
await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID)))
}
await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID)))
async function printWorkspace(workspaceID: string) {
const workspace = await Database.use((tx) =>
tx
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0]),
)
function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any[]>): Promise<any[]> {
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
await printTable("Billing", (tx) =>
tx
.select({
balance: BillingTable.balance,
customerID: BillingTable.customerID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id))
.then(
(rows) =>
rows.map((row) => ({
...row,
balance: `$${(row.balance / 100000000).toFixed(2)}`,
}))[0],
),
)
await printTable("Payments", (tx) =>
tx
.select({
amount: PaymentTable.amount,
paymentID: PaymentTable.paymentID,
invoiceID: PaymentTable.invoiceID,
timeCreated: PaymentTable.timeCreated,
timeRefunded: PaymentTable.timeRefunded,
})
.from(PaymentTable)
.where(eq(PaymentTable.workspaceID, workspace.id))
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
.limit(100)
.then((rows) =>
rows.map((row) => ({
...row,
amount: `$${(row.amount / 100000000).toFixed(2)}`,
paymentID: row.paymentID
? `https://dashboard.stripe.com/acct_1RszBH2StuRr0lbX/payments/${row.paymentID}`
: null,
})),
),
)
await printTable("Usage", (tx) =>
tx
.select({
model: UsageTable.model,
provider: UsageTable.provider,
inputTokens: UsageTable.inputTokens,
outputTokens: UsageTable.outputTokens,
reasoningTokens: UsageTable.reasoningTokens,
cacheReadTokens: UsageTable.cacheReadTokens,
cacheWrite5mTokens: UsageTable.cacheWrite5mTokens,
cacheWrite1hTokens: UsageTable.cacheWrite1hTokens,
cost: UsageTable.cost,
timeCreated: UsageTable.timeCreated,
})
.from(UsageTable)
.where(eq(UsageTable.workspaceID, workspace.id))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(10)
.then((rows) =>
rows.map((row) => ({
...row,
cost: `$${(row.cost / 100000000).toFixed(2)}`,
})),
),
)
}
function printHeader(title: string) {
console.log()
console.log("─".repeat(title.length))
console.log(`${title}`)
console.log("─".repeat(title.length))
}
function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any>): Promise<any> {
return Database.use(async (tx) => {
const data = await callback(tx)
console.log(`== ${title} ==`)
console.table(data)
console.log(`\n== ${title} ==`)
if (data.length === 0) {
console.log("(no data)")
} else {
console.table(data)
}
return data
})
}

View File

@@ -0,0 +1,166 @@
import { Billing } from "../src/billing.js"
import { Database, eq, and, sql } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js"
import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js"
const workspaceID = process.argv[2]
const email = process.argv[3]
if (!workspaceID || !email) {
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
process.exit(1)
}
// Look up the Stripe customer by email
const customers = await Billing.stripe().customers.list({ email, limit: 1 })
const customer = customers.data[0]
if (!customer) {
console.error(`Error: No Stripe customer found for email ${email}`)
process.exit(1)
}
const customerID = customer.id
// Get the subscription id
const subscriptions = await Billing.stripe().subscriptions.list({ customer: customerID, limit: 1 })
const subscription = subscriptions.data[0]
if (!subscription) {
console.error(`Error: Customer ${customerID} does not have a subscription`)
process.exit(1)
}
const subscriptionID = subscription.id
// Validate the subscription is $200
const amountInCents = subscription.items.data[0]?.price.unit_amount ?? 0
if (amountInCents !== 20000) {
console.error(`Error: Subscription amount is $${amountInCents / 100}, expected $200`)
process.exit(1)
}
// Check if subscription is already tied to another workspace
const existingSubscription = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.then((rows) => rows[0]),
)
if (existingSubscription) {
console.error(
`Error: Subscription ${subscriptionID} is already tied to workspace ${existingSubscription.workspaceID}`,
)
process.exit(1)
}
// Look up the workspace billing and check if it already has a customer id or subscription
const billing = await Database.use((tx) =>
tx
.select({ customerID: BillingTable.customerID, subscriptionID: BillingTable.subscriptionID })
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (billing?.subscriptionID) {
console.error(`Error: Workspace ${workspaceID} already has a subscription: ${billing.subscriptionID}`)
process.exit(1)
}
if (billing?.customerID) {
console.warn(
`Warning: Workspace ${workspaceID} already has a customer id: ${billing.customerID}, replacing with ${customerID}`,
)
}
// Get the latest invoice and payment from the subscription
const invoices = await Billing.stripe().invoices.list({
subscription: subscriptionID,
limit: 1,
expand: ["data.payments"],
})
const invoice = invoices.data[0]
const invoiceID = invoice?.id
const paymentID = invoice?.payments?.data[0]?.payment.payment_intent as string | undefined
// Get the default payment method from the customer
const paymentMethodID = (customer.invoice_settings.default_payment_method ?? subscription.default_payment_method) as
| string
| null
const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.retrieve(paymentMethodID) : null
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
const paymentMethodType = paymentMethod?.type ?? null
// Look up the user by email via AuthTable
const auth = await Database.use((tx) =>
tx
.select({ accountID: AuthTable.accountID })
.from(AuthTable)
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
.then((rows) => rows[0]),
)
if (!auth) {
console.error(`Error: No user found with email ${email}`)
process.exit(1)
}
// Look up the user in the workspace
const user = await Database.use((tx) =>
tx
.select({ id: UserTable.id })
.from(UserTable)
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, auth.accountID)))
.then((rows) => rows[0]),
)
if (!user) {
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`)
process.exit(1)
}
// Set workspaceID in Stripe customer metadata
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
await Database.transaction(async (tx) => {
// Set customer id, subscription id, and payment method on workspace billing
await tx
.update(BillingTable)
.set({
customerID,
subscriptionID,
paymentMethodID,
paymentMethodLast4,
paymentMethodType,
})
.where(eq(BillingTable.workspaceID, workspaceID))
// Set current time as timeSubscribed on user
await tx
.update(UserTable)
.set({
timeSubscribed: sql`now()`,
})
.where(eq(UserTable.id, user.id))
// Create a row in payments table
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
customerID,
invoiceID,
paymentID,
})
})
console.log(`Successfully onboarded workspace ${workspaceID}`)
console.log(` Customer ID: ${customerID}`)
console.log(` Subscription ID: ${subscriptionID}`)
console.log(
` Payment Method: ${paymentMethodID ?? "(none)"} (${paymentMethodType ?? "unknown"} ending in ${paymentMethodLast4 ?? "????"})`,
)
console.log(` User ID: ${user.id}`)
console.log(` Invoice ID: ${invoiceID ?? "(none)"}`)
console.log(` Payment ID: ${paymentID ?? "(none)"}`)

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "path"
import { BlackData } from "../src/black"
const stage = process.argv[2]
if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1]
if (!value) throw new Error("ZEN_BLACK not found")
// validate value
BlackData.validate(JSON.parse(value))
// update the secret
await $`bun sst secret set ZEN_BLACK ${value} --stage ${stage}`

View File

@@ -18,15 +18,17 @@ const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
const value5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
const value6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
const value7 = lines.find((line) => line.startsWith("ZEN_MODELS7"))?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 not found")
if (!value3) throw new Error("ZEN_MODELS3 not found")
if (!value4) throw new Error("ZEN_MODELS4 not found")
if (!value5) throw new Error("ZEN_MODELS5 not found")
if (!value6) throw new Error("ZEN_MODELS6 not found")
if (!value7) throw new Error("ZEN_MODELS7 not found")
// validate value
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6))
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6 + value7))
// update the secret
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
@@ -35,3 +37,4 @@ await $`bun sst secret set ZEN_MODELS3 ${value3} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS4 ${value4} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS5 ${value5} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS6 ${value6} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS7 ${value7} --stage ${stage}`

View File

@@ -18,14 +18,16 @@ const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
const value5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
const value6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
const value7 = lines.find((line) => line.startsWith("ZEN_MODELS7"))?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 not found")
if (!value3) throw new Error("ZEN_MODELS3 not found")
if (!value4) throw new Error("ZEN_MODELS4 not found")
if (!value5) throw new Error("ZEN_MODELS5 not found")
if (!value6) throw new Error("ZEN_MODELS6 not found")
if (!value7) throw new Error("ZEN_MODELS7 not found")
// validate value
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6))
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6 + value7))
// update the secret
await $`bun sst secret set ZEN_MODELS1 ${value1}`
@@ -34,3 +36,4 @@ await $`bun sst secret set ZEN_MODELS3 ${value3}`
await $`bun sst secret set ZEN_MODELS4 ${value4}`
await $`bun sst secret set ZEN_MODELS5 ${value5}`
await $`bun sst secret set ZEN_MODELS6 ${value6}`
await $`bun sst secret set ZEN_MODELS7 ${value7}`

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