Compare commits

...

396 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
Dax Raad
0cf0294787 anomalyco/opencode 2026-01-02 16:09:06 -05:00
Dax
3c41e4e8f1 chore: rename repo references from sst/opencode to anomalyco/opencode (#6687)
Co-authored-by: Github Action <action@github.com>
2026-01-02 16:02:52 -05:00
Matt Silverlock
66bc046503 fix: merge instructions arrays across config files (#6663) 2026-01-02 13:55:40 -06:00
Matt Silverlock
6e68ea034c fix: handle actions/checkout v6 credential storage change (#6667) 2026-01-02 13:54:43 -06:00
Adam
c51fa7cb24 feat: add /compact session command 2026-01-02 12:40:37 -06:00
Adam
a4c67515c9 fix(desktop): tweak window drag region layout 2026-01-02 12:40:36 -06:00
Adam
1d2d710fce fix: relax request timeouts 2026-01-02 12:40:36 -06:00
Aiden Cline
2fd97377f6 test: fix transform test 2026-01-02 12:38:44 -06:00
Aiden Cline
47ebb2973f test: add message-v2 test 2026-01-02 12:28:40 -06:00
Aiden Cline
49d7ccd1db fix: variant for minimal 2026-01-02 12:28:40 -06:00
Aiden Cline
c996f3d847 chore: ensure empty message isnt sent 2026-01-02 12:28:40 -06:00
Mike English
70881b2937 fix: cloudflare-ai-gateway sdk.chat undefined error (#6407) 2026-01-02 11:24:13 -06:00
rari404
d8753cda02 refactor: use Bun.sleep instead of Promise setTimeout (#6620) 2026-01-02 11:12:02 -06:00
steez
2685de2a33 feat(theme): add Osaka Jade theme (#6609) 2026-01-02 10:26:01 -06:00
Albin Groen
ddb1ec294e fix(ui): fix slight vertical overflow in project selector (#6589) 2026-01-02 06:24:20 -06:00
OpeOginni
fbd9677932 fix(desktop): Properly decode session id for permission context (#6580) 2026-01-02 06:22:50 -06:00
GitHub Action
814e513db7 ignore: update download stats 2026-01-02 2026-01-02 12:04:42 +00:00
Luke Parker
c600114db9 fix(share): handle NotFoundError for non-shared sessions in sync (#6634) 2026-01-02 04:16:12 -06:00
Dax Raad
038cff4a93 core: improve plugin loading to handle builtin plugin failures gracefully 2026-01-01 23:15:04 -05:00
Dax Raad
741cb9c0ef ci 2026-01-01 22:44:22 -05:00
GitHub Action
38e5adc491 chore: generate 2026-01-02 03:12:21 +00:00
Dax Raad
389a5fc017 tui: add reload functionality and improve lazy utility with reset capability 2026-01-01 22:11:43 -05:00
opencode
d60393835c release: v1.0.224 2026-01-02 03:05:57 +00:00
Adam
e6ba241045 wip(app): progress 2026-01-01 21:03:08 -06:00
Adam
cd2c160cf6 fix(app): startup time 2026-01-01 21:03:08 -06:00
Adam
0f34634c52 chore: cleanup 2026-01-01 21:03:07 -06:00
Adam
a5a569f892 chore: cleanup 2026-01-01 21:03:07 -06:00
Adam
afc1825cf5 wip(app): progress 2026-01-01 21:03:06 -06:00
Adam
6b4c433e14 wip(app): progress 2026-01-01 21:03:06 -06:00
Adam
797d8425e0 wip(app): progress 2026-01-01 21:03:06 -06:00
Adam
260eef2d66 wip(app): progress 2026-01-01 21:03:05 -06:00
Adam
93f1e1afb8 wip(desktop): progress 2026-01-01 21:03:05 -06:00
Adam
6acd16dde4 wip(desktop): progress 2026-01-01 21:03:04 -06:00
Adam
6647b1e22f wip(desktop): progress 2026-01-01 21:03:04 -06:00
Adam
b8872d9d20 wip(desktop): progress 2026-01-01 21:03:03 -06:00
Adam
78940d5b7e wip(app): file context 2026-01-01 21:03:03 -06:00
Dax Raad
b84a1f714b tui: remove memory leak fixes documentation after implementation 2026-01-01 21:40:28 -05:00
GitHub Action
07c008fe3d chore: generate 2026-01-02 02:28:48 +00:00
Dax Raad
dad9c917d2 tui: fix memory leaks in session management and improve permission error handling 2026-01-01 21:28:11 -05:00
Dax Raad
2aaea71eb3 tui: add heap snapshot option to system menu for debugging memory usage 2026-01-01 21:18:28 -05:00
Dax Raad
db8d83b53d tui: fix permission tests for new evaluate function signature 2026-01-01 21:01:34 -05:00
Dax Raad
963f407062 tui: improve permission error handling and evaluation logic 2026-01-01 21:01:00 -05:00
Dax Raad
4f1ef93910 ignore 2026-01-01 20:42:06 -05:00
Dillon Mulroy
05eee679a3 feat: add assistant metadata to session export (#6611) 2026-01-01 18:56:23 -06:00
Mani Sundararajan
154c52c4d9 fix: windows fallback for "less" cmd in session list (#6515) 2026-01-01 18:53:29 -06:00
Daniel Polito
680db7b9e4 Desktop: Improve Resize Handle (#6608) 2026-01-01 18:26:34 -06:00
Aiden Cline
7aa1dbe873 test: fix bash test 2026-01-01 17:53:20 -06:00
Aiden Cline
76186d19f3 fix: ensure new permissions changes work for special case bash commands like rm, cd, etc 2026-01-01 17:27:23 -06:00
Aiden Cline
7760b33956 ignore: comment out repo default for ask 2026-01-01 17:13:31 -06:00
GitHub Action
99794c25b0 chore: generate 2026-01-01 22:54:43 +00:00
Dax
351ddeed91 Permission rework (#6319)
Co-authored-by: Github Action <action@github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-01-01 17:54:11 -05:00
Aiden Cline
dccb8875ad core: fix import command regex 2026-01-01 15:18:32 -06:00
Frank
5f2be55e54 docs: update zen processing fee 2026-01-01 16:10:13 -05:00
alcpereira
8b35d56a48 fix: remove outdated Haiku filter for GitHub Copilot (#6593) 2026-01-01 14:04:31 -06:00
Aiden Cline
9be944a2d2 core: fix stats command day calculation and time filtering 2026-01-01 13:25:26 -06:00
Aiden Cline
5138f9250e ignore: keep the process exit logic 2026-01-01 13:05:08 -06:00
Aiden Cline
e503654252 core: make installdeps non blocking 2026-01-01 13:00:39 -06:00
Aiden Cline
8ebc601ea2 core: use --no-cache when behind proxy to avoid hangs 2026-01-01 12:46:25 -06:00
Saatvik Arya
7a3ff5b98f fix(session): check for context overflow mid-turn in finish-step (#6480) 2026-01-01 12:03:18 -06:00
Lekë Dobruna
3b03324578 fix: display error if invalid agent is used in a command (#6578) 2026-01-01 11:39:21 -06:00
GitHub Action
35fff0ca70 chore: generate 2026-01-01 17:35:37 +00:00
Tom
dc8586371c fix(server): add Content-Type headers for proxied static assets (#6587) 2026-01-01 11:35:04 -06:00
GitHub Action
41f9a58c27 ignore: update download stats 2026-01-01 2026-01-01 12:04:57 +00:00
opencode
01237c5325 release: v1.0.223 2026-01-01 11:25:32 +00:00
Adam
6e7fc30f94 feat(app): context window window 2026-01-01 05:23:07 -06:00
Adam
03733b0505 fix(util): checksum defensiveness 2026-01-01 05:23:07 -06:00
Adam
d1a4295a32 fix(util): checksum defensiveness 2026-01-01 05:23:06 -06:00
Adam
6341ed506c fix(app): update primitive colors 2026-01-01 05:23:06 -06:00
Adam
ed745df375 fix(app): update primitive colors 2026-01-01 05:23:05 -06:00
GitHub Action
80db008419 chore: generate 2026-01-01 04:59:38 +00:00
Aiden Cline
4039670a24 Reapply "fix(tui): don't show 'Agent not found' toast for subagents (#6528)"
This reverts commit 97a0fd1d54.
2025-12-31 22:58:06 -06:00
Github Action
d59357c89b Update Nix flake.lock and hashes 2026-01-01 01:21:05 +00:00
opencode
3331b0600a release: v1.0.222 2026-01-01 01:21:04 +00:00
Luke Parker
c131dd0829 fix(desktop): Windows support for PTY and cross-platform build scripts (#6508) 2025-12-31 19:18:07 -06:00
Adam
1c25f1fae0 feat(desktop): in-app update toasts 2025-12-31 18:19:53 -06:00
Adam
2da71e0a50 feat(desktop): lose the summaries 2025-12-31 18:16:52 -06:00
Daniel Polito
87978b1c17 Desktop: Add Subagents Mention Support (#6540) 2025-12-31 18:07:45 -06:00
GitHub Action
63d2b21b8f chore: generate 2025-12-31 22:16:32 +00:00
Aiden Cline
9a1dc1ffe4 core: prevent TimeoutOverflowWarning by capping setTimeout delay to max 32-bit signed integer 2025-12-31 16:15:49 -06:00
opencode
c93e7621be release: v1.0.221 2025-12-31 21:10:33 +00:00
Adam
e842205550 chore: cleanup 2025-12-31 15:08:00 -06:00
Adam
b2aa387376 feat(app): better model selector 2025-12-31 15:07:59 -06:00
Aiden Cline
34aecda47c tweak: default to ai-sdk/opeai-compatible if no npm package provided 2025-12-31 14:54:21 -06:00
Aiden Cline
b419b0ec55 Reapply "tweak: adjust keys for uniqueness calculations to use provider/model"
This reverts commit 9d32a0354f1db3ea4893f4ad00900c6420ab78c6.
2025-12-31 14:54:21 -06:00
Aiden Cline
538ac208e1 Revert "tweak: adjust keys for uniqueness calculations to use provider/model"
This reverts commit eb81994a18.
2025-12-31 14:54:21 -06:00
Adam
16957fd107 fix(app): auto-accept colors 2025-12-31 14:35:41 -06:00
Adam
7f3a0b8e5c fix(desktop): tooltip colors 2025-12-31 14:20:21 -06:00
Adam
d4a2652eda feat(desktop): better affordance for auto-accept 2025-12-31 14:04:44 -06:00
Adam
7a4bfbe56d fix(app): text selection 2025-12-31 13:32:44 -06:00
Adam
31e2c8b5e9 wip: input changes 2025-12-31 13:31:46 -06:00
Adam
eab23738a8 chore: cleanup 2025-12-31 13:13:50 -06:00
Adam
93845db462 fix(desktop): don't show notifs if auto-accepting 2025-12-31 13:12:31 -06:00
Adam
65bc72098b fix(desktop): more defensive access 2025-12-31 13:12:30 -06:00
Adam
b5546dce80 wip(app): better variant toggle 2025-12-31 13:12:30 -06:00
Adam
3807364e73 chore(app): tool args cleanup 2025-12-31 13:12:29 -06:00
Adam
3a1cfa6c73 chore(app): keybind tooltip component 2025-12-31 13:12:29 -06:00
Adam
a2857bba83 fix(desktop): prompt input cleanup 2025-12-31 13:12:28 -06:00
Aiden Cline
97a0fd1d54 Revert "fix(tui): don't show 'Agent not found' toast for subagents (#6528)"
This reverts commit 87f9ebd17c.
2025-12-31 13:11:12 -06:00
Vlad Temian
87f9ebd17c fix(tui): don't show 'Agent not found' toast for subagents (#6528) 2025-12-31 13:04:23 -06:00
Aiden Cline
e7422ee782 chore: rm unused import 2025-12-31 12:11:42 -06:00
Aiden Cline
dc3e731afe ci: tweak changelog ensure all cmd/ things fall under tui section 2025-12-31 12:05:35 -06:00
GitHub Action
de50e7f9b9 chore: generate 2025-12-31 17:35:47 +00:00
Marcel de Vries
f3db966317 Add support for LSP workspace/didChangeWatchedFiles (#6524)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-31 11:35:09 -06:00
opencode
decc616c80 release: v1.0.220 2025-12-31 17:30:57 +00:00
Aiden Cline
e0450e74fb fix: plugin & mode globs 2025-12-31 11:28:06 -06:00
GitHub Action
094af4dc7d chore: generate 2025-12-31 17:19:02 +00:00
Michael Ramos
2dc14718aa docs: Add plannotator plugin (#6510) 2025-12-31 11:18:28 -06:00
Eric Guo
b97d20f252 feat(cli): New debug agent <name> subcommand (#6529) 2025-12-31 11:16:03 -06:00
Anzul Aqeel
fcfcdd95e9 fix: Clarify agent-name placeholder in tips (#6520) 2025-12-31 11:15:07 -06:00
opencode-agent[bot]
c42bd492ea docs: new configurable CORS option (#6522)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2025-12-31 11:11:20 -06:00
Frank
840fe030ab zen: fix fee instruction 2025-12-31 11:54:01 -05:00
Adam
a7c4f83ca2 fix(desktop): remove status bar, new elements in header 2025-12-31 10:22:17 -06:00
Adam
ed70a07201 fix(desktop): cleanup user message style 2025-12-31 10:22:16 -06:00
Adam
18a5eb205f fix(desktop): better new session button 2025-12-31 10:22:16 -06:00
Paolo Ricciuti
5249f04ea0 fix: display MCP tag for prompts in autocomplete but not in prompt (#6531) 2025-12-31 09:34:36 -06:00
Adam
6e3ead198e fix(ui): radio group styles 2025-12-31 09:26:14 -06:00
GitHub Action
4309d439f5 chore: generate 2025-12-31 15:24:05 +00:00
Adam
2ec6a21cc0 feat(desktop): unified diff toggle 2025-12-31 09:23:24 -06:00
Adam
ebf5ad25c5 fix(desktop): not rendering large sessions 2025-12-31 08:29:36 -06:00
opencode
8aa34ab9f3 release: v1.0.219 2025-12-31 14:01:59 +00:00
Adam
50ef866a02 fix(core): mdns fails if service already registered 2025-12-31 07:33:49 -06:00
Adam
3650fefe2d fix(desktop): don't expand tools by default 2025-12-31 07:10:47 -06:00
GitHub Action
22091c29f1 ignore: update download stats 2025-12-31 2025-12-31 12:04:56 +00:00
Adam
e7e89dc5a6 chore: cleanup 2025-12-31 04:47:24 -06:00
Adam
34e9392bb4 chore: daytona skip preview warning 2025-12-31 04:22:53 -06:00
GitHub Action
05c3bc27ff chore: generate 2025-12-31 09:51:12 +00:00
Adam
b1a6333d17 feat(core): configurable cors hosts 2025-12-31 03:50:29 -06:00
Aiden Cline
5c9d619620 docs: add variants docs (#6516)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2025-12-31 01:17:50 -06:00
GitHub Action
dfb9caa2a9 chore: generate 2025-12-31 06:51:59 +00:00
361 changed files with 59097 additions and 7242 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

@@ -26,7 +26,7 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Run opencode
uses: sst/opencode/github@latest
uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_PERMISSION: '{"bash": "deny"}'

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

@@ -2,11 +2,9 @@ name: test
on:
push:
branches-ignore:
- production
branches:
- dev
pull_request:
branches-ignore:
- production
workflow_dispatch:
jobs:
test:

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

@@ -1,10 +0,0 @@
---
description: Use this agent when you are asked to commit and push code changes to a git repository.
mode: subagent
---
You commit and push to git
Commit messages should be brief since they are used to generate release notes.
Messages should say WHY the change was made and not WHAT was changed.

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,8 +10,14 @@
"options": {},
},
},
"mcp": {},
"mcp": {
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
},
},
"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

@@ -1,11 +1,4 @@
## Debugging
- To test opencode in the `packages/opencode` directory you can run `bun dev`
## SDK
To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
## Tool Calling
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- the default branch in this repo is `dev`

View File

@@ -14,10 +14,10 @@ However, any UI or core product feature must go through a design review with the
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels:
- [`help wanted`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/sst/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
- [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
> [!NOTE]
> PRs that ignore these guardrails will likely be closed.
@@ -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

@@ -11,7 +11,7 @@
<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/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
<a href="https://github.com/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)
@@ -28,10 +28,11 @@ 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:sst/opencode for latest dev branch
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
```
> [!TIP]
@@ -39,7 +40,7 @@ nix run nixpkgs#opencode # or github:sst/opencode for latest dev branc
### Desktop App (BETA)
OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/sst/opencode/releases) or [opencode.ai/download](https://opencode.ai/download).
OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/anomalyco/opencode/releases) or [opencode.ai/download](https://opencode.ai/download).
| Platform | Download |
| --------------------- | ------------------------------------- |
@@ -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

@@ -11,7 +11,7 @@
<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/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
<a href="https://github.com/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)
@@ -28,10 +28,11 @@ 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:sst/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最新開發分支
mise use -g github:anomalyco/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
```
> [!TIP]
@@ -39,7 +40,7 @@ nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最
### 桌面應用程式 (BETA)
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/sst/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。
| 平台 | 下載連結 |
| --------------------- | ------------------------------------- |

380
STATS.md
View File

@@ -1,188 +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) |
| 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.218",
"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.218",
"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.218",
"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.218",
"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.218",
"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.218",
"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.218",
"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.218",
"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.218",
"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,16 +286,17 @@
"@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:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2",
"bun-pty": "0.4.4",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -348,7 +350,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.218",
"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.218",
"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.218",
"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.218",
"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.218",
"version": "1.1.6",
"dependencies": {
"zod": "catalog:",
},
@@ -441,7 +444,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.218",
"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=="],
@@ -2044,7 +2053,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="],
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
@@ -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=="],

View File

@@ -1,2 +1,6 @@
[install]
exact = true
[test]
root = "./do-not-run-tests-from-root"

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1767026758,
"narHash": "sha256-7fsac/f7nh/VaKJ/qm3I338+wAJa/3J57cOGpXi0Sbg=",
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "346dd96ad74dc4457a9db9de4f4f57dab2e5731d",
"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,12 +82,12 @@ 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
- name: Run opencode
uses: sst/opencode/github@latest
uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
@@ -98,7 +98,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
## Support
This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/sst/opencode/issues.
This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/anomalyco/opencode/issues.
## Development

View File

@@ -41,7 +41,7 @@ runs:
id: version
shell: bash
run: |
VERSION=$(curl -sf https://api.github.com/repos/sst/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
VERSION=$(curl -sf https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT
- name: Cache opencode

View File

@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await new Promise((resolve) => setTimeout(resolve, 300))
await Bun.sleep(300)
} while (retry++ < 30)
if (!connected) {

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/sst/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/sst/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/sst/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/sst/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/sst/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-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
"nodeModules": "sha256-rNGq0yjL5ZHYVg+zyV4nFPug4gqhKhyOnfebaufyd34="
}

View File

@@ -125,7 +125,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
It combines a TypeScript/JavaScript core with a Go-based TUI
to provide an interactive AI coding experience.
'';
homepage = "https://github.com/sst/opencode";
homepage = "https://github.com/anomalyco/opencode";
license = lib.licenses.mit;
platforms = [
"aarch64-linux"

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

@@ -10,7 +10,8 @@
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'"
"hello": "echo 'Hello World!'",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
"packages": [
@@ -75,7 +76,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/sst/opencode"
"url": "https://github.com/anomalyco/opencode"
},
"license": "MIT",
"prettier": {

View File

@@ -1,28 +1,13 @@
# Agent Guidelines for @opencode/app
## Debugging
## Build/Test Commands
- 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.
- **Development**: `bun run dev` (starts Vite dev server on port 3000)
- **Build**: `bun run build` (production build)
- **Preview**: `bun run serve` (preview production build)
- **Validation**: Use `bun run typecheck` only - do not build or run project for validation
- **Testing**: Do not create or run automated tests
## SolidJS
## Code Style
- Always prefer `createStore` over multiple `createSignal` calls
- **Framework**: SolidJS with TypeScript
- **Imports**: Use `@/` alias for src/ directory (e.g., `import Button from "@/ui/button"`)
- **Formatting**: Prettier configured with semicolons disabled, 120 character line width
- **Components**: Use function declarations, splitProps for component props
- **Types**: Define interfaces for component props, avoid `any` type
- **CSS**: TailwindCSS with custom CSS variables theme system
- **Naming**: PascalCase for components, camelCase for variables/functions, snake_case for file names
- **File Structure**: UI primitives in `/ui/`, higher-level components in `/components/`, pages in `/pages/`, providers in `/providers/`
## Tool Calling
## Key Dependencies
- SolidJS, @solidjs/router, @kobalte/core (UI primitives)
- TailwindCSS 4.x with @tailwindcss/vite
- Custom theme system with CSS variables
No special rules files found.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.

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.218",
"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"
@@ -10,24 +10,30 @@ import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync"
import { PermissionProvider } from "@/context/permission"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { ServerProvider, useServer } from "@/context/server"
import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
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 }
}
}
@@ -43,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 (
@@ -52,58 +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>
<LayoutProvider>
<NotificationProvider>
<Router
root={(props) => (
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
)}
>
<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>
<PromptProvider>
<Session />
</PromptProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</NotificationProvider>
</LayoutProvider>
</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

@@ -6,11 +6,11 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { useFile } from "@/context/file"
export function DialogSelectFile() {
const layout = useLayout()
const local = useLocal()
const file = useFile()
const dialog = useDialog()
const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -20,11 +20,14 @@ export function DialogSelectFile() {
<List
search={{ placeholder: "Search files", autofocus: true }}
emptyMessage="No files found"
items={local.file.searchFiles}
items={file.searchFiles}
key={(x) => x}
onSelect={(path) => {
if (path) {
tabs().open("file://" + path)
const value = file.tab(path)
tabs().open(value)
file.load(path)
layout.review.open()
}
dialog.close()
}}

View File

@@ -1,4 +1,5 @@
import { Component, createMemo, Show } from "solid-js"
import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, createMemo, createSignal, JSX, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers"
@@ -9,9 +10,12 @@ import { List } from "@opencode-ai/ui/list"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogManageModels } from "./dialog-manage-models"
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
const ModelList: Component<{
provider?: string
class?: string
onSelect: () => void
}> = (props) => {
const local = useLocal()
const dialog = useDialog()
const models = createMemo(() =>
local.model
@@ -20,6 +24,70 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
)
return (
<List
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
search={{ placeholder: "Search models", autofocus: true }}
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
props.onSelect()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
)
}
export const ModelSelectorPopover: Component<{
provider?: string
children: JSX.Element
}> = (props) => {
const [open, setOpen] = createSignal(false)
return (
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
const dialog = useDialog()
return (
<Dialog
title="Select model"
@@ -34,43 +102,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
</Button>
}
>
<List
search={{ placeholder: "Search models", autofocus: true }}
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-3">
<span>{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
<Button
variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start"

View File

@@ -1,215 +0,0 @@
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLayout } from "@/context/layout"
import { Session } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { Popover } from "@opencode-ai/ui/popover"
import { Select } from "@opencode-ai/ui/select"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Decode } from "@opencode-ai/util/encode"
import { useCommand } from "@/context/command"
import { getFilename } from "@opencode-ai/util/path"
import { A, useParams } from "@solidjs/router"
import { createMemo, createResource, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { iife } from "@opencode-ai/util/iife"
export function Header(props: {
navigateToProject: (directory: string) => void
navigateToSession: (session: Session | undefined) => void
onMobileMenuToggle?: () => void
}) {
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<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"
onClick={props.onMobileMenuToggle}
>
<Icon name="menu" size="small" />
</button>
<A
href="/"
classList={{
"hidden xl:flex": true,
"w-12 shrink-0 px-4 py-3.5": true,
"items-center justify-start self-stretch": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
data-tauri-drag-region
>
<Mark class="shrink-0" />
</A>
<div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
<Show when={layout.projects.list().length > 0 && params.dir}>
{(directory) => {
const currentDirectory = createMemo(() => base64Decode(directory()))
const store = createMemo(() => globalSync.child(currentDirectory())[0])
const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => store().config.share !== "disabled")
return (
<>
<div class="flex items-center gap-3 min-w-0">
<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={currentDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</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={props.navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<Tooltip
class="hidden xl:block"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
New session
</Button>
</Tooltip>
</Show>
</div>
<div class="flex items-center gap-4">
<Show when={currentSession()?.summary?.files}>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle review</span>
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
</Show>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle terminal</span>
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: currentDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
)
return (
<Show when={url()}>
{(url) => <TextField value={url()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</div>
</>
)
}}
</Show>
</div>
</header>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,25 @@
import { createMemo, Show } from "solid-js"
import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { useSync } from "@/context/sync"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
export function SessionContextUsage() {
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
}
export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync()
const params = useParams()
const layout = useLayout()
const variant = createMemo(() => props.variant ?? "button")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const cost = createMemo(() => {
@@ -19,7 +31,11 @@ export function SessionContextUsage() {
})
const context = createMemo(() => {
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
const last = messages().findLast((x) => {
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
}) as AssistantMessage
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
@@ -30,28 +46,57 @@ export function SessionContextUsage() {
}
})
return (
<Show when={context?.()}>
{(ctx) => (
<Tooltip
value={
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
<span class="opacity-70 text-right">Tokens</span>
<span class="text-left">{ctx().tokens}</span>
<span class="opacity-70 text-right">Usage</span>
<span class="text-left">{ctx().percentage ?? 0}%</span>
<span class="opacity-70 text-right">Cost</span>
<span class="text-left">{cost()}</span>
const openContext = () => {
if (!params.id) return
layout.review.open()
tabs().open("context")
tabs().setActive("context")
}
const circle = () => (
<div class="p-1">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
</div>
)
const tooltipValue = () => (
<div>
<Show when={context()}>
{(ctx) => (
<>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().tokens}</span>
<span class="text-text-invert-base">Tokens</span>
</div>
}
placement="top"
>
<div class="flex items-center gap-1.5">
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
{/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
</div>
</Tooltip>
)}
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
<span class="text-text-invert-base">Usage</span>
</div>
</>
)}
</Show>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{cost()}</span>
<span class="text-text-invert-base">Cost</span>
</div>
<Show when={variant() === "button"}>
<div class="text-11-regular text-text-invert-base mt-1">Click to view context</div>
</Show>
</div>
)
return (
<Show when={params.id}>
<Tooltip value={tooltipValue()} placement="top">
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={true}>
<Button type="button" variant="ghost" class="size-6" onClick={openContext}>
{circle()}
</Button>
</Match>
</Switch>
</Tooltip>
</Show>
)
}

View File

@@ -0,0 +1,5 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"

View File

@@ -0,0 +1,425 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown"
import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
interface SessionContextTabProps {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
export function SessionContextTab(props: SessionContextTabProps) {
const params = useParams()
const sync = useSync()
const ctx = createMemo(() => {
const last = props.messages().findLast((x) => {
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
}) as AssistantMessage
if (!last) return
const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
const model = provider?.models[last.modelID]
const limit = model?.limit.context
const input = last.tokens.input
const output = last.tokens.output
const reasoning = last.tokens.reasoning
const cacheRead = last.tokens.cache.read
const cacheWrite = last.tokens.cache.write
const total = input + output + reasoning + cacheRead + cacheWrite
const usage = limit ? Math.round((total / limit) * 100) : null
return {
message: last,
provider,
model,
limit,
input,
output,
reasoning,
cacheRead,
cacheWrite,
total,
usage,
}
})
const cost = createMemo(() => {
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const counts = createMemo(() => {
const all = props.messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return {
all: all.length,
user,
assistant,
}
})
const systemPrompt = createMemo(() => {
const msg = props.visibleUserMessages().findLast((m) => !!m.system)
const system = msg?.system
if (!system) return
const trimmed = system.trim()
if (!trimmed) return
return trimmed
})
const number = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString()
}
const percent = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toString() + "%"
}
const time = (value: number | undefined) => {
if (!value) return "—"
return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED)
}
const providerLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
return c.provider?.name ?? c.message.providerID
})
const modelLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
if (c.model?.name) return c.model.name
return c.message.modelID
})
const breakdown = createMemo(
on(
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c) return []
const input = c.input
if (!input) return []
const out = {
system: systemPrompt()?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
}
for (const msg of props.messages()) {
const parts = (sync.data.part[msg.id] ?? []) as Part[]
if (msg.role === "user") {
for (const part of parts) {
if (part.type === "text") out.user += part.text.length
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
if (part.type === "agent") out.user += part.source?.value.length ?? 0
}
continue
}
if (msg.role === "assistant") {
for (const part of parts) {
if (part.type === "text") out.assistant += part.text.length
if (part.type === "reasoning") out.assistant += part.text.length
if (part.type === "tool") {
out.tool += Object.keys(part.state.input).length * 16
if (part.state.status === "pending") out.tool += part.state.raw.length
if (part.state.status === "completed") out.tool += part.state.output.length
if (part.state.status === "error") out.tool += part.state.error.length
}
}
}
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const system = estimateTokens(out.system)
const user = estimateTokens(out.user)
const assistant = estimateTokens(out.assistant)
const tool = estimateTokens(out.tool)
const estimated = system + user + assistant + tool
const pct = (tokens: number) => (tokens / input) * 100
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
return [
{
key: "system",
label: "System",
tokens: tokens.system,
width: pct(tokens.system),
percent: pctLabel(tokens.system),
color: "var(--syntax-info)",
},
{
key: "user",
label: "User",
tokens: tokens.user,
width: pct(tokens.user),
percent: pctLabel(tokens.user),
color: "var(--syntax-success)",
},
{
key: "assistant",
label: "Assistant",
tokens: tokens.assistant,
width: pct(tokens.assistant),
percent: pctLabel(tokens.assistant),
color: "var(--syntax-property)",
},
{
key: "tool",
label: "Tool Calls",
tokens: tokens.tool,
width: pct(tokens.tool),
percent: pctLabel(tokens.tool),
color: "var(--syntax-warning)",
},
{
key: "other",
label: "Other",
tokens: tokens.other,
width: pct(tokens.other),
percent: pctLabel(tokens.other),
color: "var(--syntax-comment)",
},
].filter((x) => x.tokens > 0)
}
if (estimated <= input) {
return build({ system, user, assistant, tool, other: input - estimated })
}
const scale = input / estimated
const scaled = {
system: Math.floor(system * scale),
user: Math.floor(user * scale),
assistant: Math.floor(assistant * scale),
tool: Math.floor(tool * scale),
}
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
},
),
)
function Stat(statProps: { label: string; value: JSX.Element }) {
return (
<div class="flex flex-col gap-1">
<div class="text-12-regular text-text-weak">{statProps.label}</div>
<div class="text-12-medium text-text-strong">{statProps.value}</div>
</div>
)
}
const stats = createMemo(() => {
const c = ctx()
const count = counts()
return [
{ label: "Session", value: props.info()?.title ?? params.id ?? "—" },
{ label: "Messages", value: count.all.toLocaleString() },
{ label: "Provider", value: providerLabel() },
{ label: "Model", value: modelLabel() },
{ label: "Context Limit", value: number(c?.limit) },
{ label: "Total Tokens", value: number(c?.total) },
{ label: "Usage", value: percent(c?.usage) },
{ label: "Input Tokens", value: number(c?.input) },
{ label: "Output Tokens", value: number(c?.output) },
{ label: "Reasoning Tokens", value: number(c?.reasoning) },
{ label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` },
{ label: "User Messages", value: count.user.toLocaleString() },
{ label: "Assistant Messages", value: count.assistant.toLocaleString() },
{ label: "Total Cost", value: cost() },
{ label: "Session Created", value: time(props.info()?.time.created) },
{ label: "Last Activity", value: time(c?.message.time.created) },
] satisfies { label: string; value: JSX.Element }[]
})
function RawMessageContent(msgProps: { message: Message }) {
const file = createMemo(() => {
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
return {
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
contents,
cacheKey: checksum(contents),
}
})
return <Code file={file()} overflow="wrap" class="select-text" />
}
function RawMessage(msgProps: { message: Message }) {
return (
<Accordion.Item value={msgProps.message.id}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate">
{msgProps.message.role} <span class="text-text-base"> {msgProps.message.id}</span>
</div>
<div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="bg-background-base">
<div class="p-3">
<RawMessageContent message={msgProps.message} />
</div>
</Accordion.Content>
</Accordion.Item>
)
}
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
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
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
pending = {
x: event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
}
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
const next = pending
pending = undefined
if (!next) return
props.view().setScroll("context", next)
})
}
createEffect(
on(
() => props.messages().length,
() => {
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
return (
<div
class="@container h-full overflow-y-auto no-scrollbar pb-10"
ref={(el) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll}
>
<div class="px-6 pt-4 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
</div>
<Show when={breakdown().length > 0}>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">Context Breakdown</div>
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
<For each={breakdown()}>
{(segment) => (
<div
class="h-full"
style={{
width: `${segment.width}%`,
"background-color": segment.color,
}}
/>
)}
</For>
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<For each={breakdown()}>
{(segment) => (
<div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
<div>{segment.label}</div>
<div class="text-text-weaker">{segment.percent}</div>
</div>
)}
</For>
</div>
<div class="hidden text-11-regular text-text-weaker">
Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.
</div>
</div>
</Show>
<Show when={systemPrompt()}>
{(prompt) => (
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">System Prompt</div>
<div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
<Markdown text={prompt()} class="text-12-regular" />
</div>
</div>
)}
</Show>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">Raw messages</div>
<Accordion multiple>
<For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
</Accordion>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,261 @@
import { createMemo, createResource, Show } from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useServer } from "@/context/server"
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 { 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"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Select } from "@opencode-ai/ui/select"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
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()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const command = useCommand()
const server = useServer()
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(() => 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 worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
}
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">
<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"
onClick={layout.mobileSidebar.toggle}
>
<Icon name="menu" size="small" />
</button>
<div class="px-4 flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
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"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
</div>
<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() && !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>
</Show>
</div>
<div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,89 @@
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"
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"
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(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">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { getFilename } from "@opencode-ai/util/path"
import { useFile } from "@/context/file"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
return (
<div class="flex items-center gap-x-1.5">
<FileIcon
node={{ path: props.path, type: "file" }}
classList={{
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
"grayscale-0": props.active,
}}
/>
<span class="text-14-medium">{getFilename(props.path)}</span>
</div>
)
}
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
const file = useFile()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
value={props.tab}
closeButton={
<Tooltip value="Close tab" placement="bottom">
<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
</Tooltip>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
>
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</Tabs.Trigger>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import type { JSX } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useTerminal, type LocalPTY } from "@/context/terminal"
export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element {
const terminal = useTerminal()
const sortable = createSortable(props.terminal.id)
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
value={props.terminal.id}
closeButton={
terminal.all().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
)
}
>
{props.terminal.title}
</Tabs.Trigger>
</div>
</div>
)
}

View File

@@ -1,53 +0,0 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Button } from "@opencode-ai/ui/button"
import { DialogSelectServer } from "@/components/dialog-select-server"
export function StatusBar(props: ParentProps) {
const dialog = useDialog()
const server = useServer()
const sync = useSync()
const globalSync = useGlobalSync()
const directoryDisplay = createMemo(() => {
const directory = sync.data.path.directory || ""
const home = globalSync.data.path.home || ""
const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
const branch = sync.data.vcs?.branch
return branch ? `${short}:${branch}` : short
})
return (
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<span class="text-12-regular text-text-weak">{server.name}</span>
</Button>
</div>
<Show when={directoryDisplay()}>
<span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
</Show>
</div>
<div class="flex items-center">{props.children}</div>
</div>
)
}

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

@@ -0,0 +1,311 @@
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"
import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { Persist, persisted } from "@/utils/persist"
export type FileSelection = {
startLine: number
startChar: number
endLine: number
endChar: number
}
export type SelectedLineRange = {
start: number
end: number
side?: "additions" | "deletions"
endSide?: "additions" | "deletions"
}
export type FileViewState = {
scrollTop?: number
scrollLeft?: number
selectedLines?: SelectedLineRange | null
}
export type FileState = {
path: string
name: string
loaded?: boolean
loading?: boolean
error?: string
content?: FileContent
}
function stripFileProtocol(input: string) {
if (!input.startsWith("file://")) return input
return input.slice("file://".length)
}
function stripQueryAndHash(input: string) {
const hashIndex = input.indexOf("#")
const queryIndex = input.indexOf("?")
if (hashIndex !== -1 && queryIndex !== -1) {
return input.slice(0, Math.min(hashIndex, queryIndex))
}
if (hashIndex !== -1) return input.slice(0, hashIndex)
if (queryIndex !== -1) return input.slice(0, queryIndex)
return input
}
export function selectionFromLines(range: SelectedLineRange): FileSelection {
const startLine = Math.min(range.start, range.end)
const endLine = Math.max(range.start, range.end)
return {
startLine,
endLine,
startChar: 0,
endChar: 0,
}
}
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return range
const startSide = range.side
const endSide = range.endSide ?? startSide
return {
...range,
start: range.end,
end: range.start,
side: endSide,
endSide: startSide !== endSide ? startSide : undefined,
}
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File",
init: () => {
const sdk = useSDK()
const sync = useSync()
const params = useParams()
const directory = createMemo(() => sync.data.path.directory)
function normalize(input: string) {
const root = directory()
const prefix = root.endsWith("/") ? root : root + "/"
let path = stripQueryAndHash(stripFileProtocol(input))
if (path.startsWith(prefix)) {
path = path.slice(prefix.length)
}
if (path.startsWith(root)) {
path = path.slice(root.length)
}
if (path.startsWith("./")) {
path = path.slice(2)
}
if (path.startsWith("/")) {
path = path.slice(1)
}
return path
}
function tab(input: string) {
const path = normalize(input)
return `file://${path}`
}
function pathFromTab(tabValue: string) {
if (!tabValue.startsWith("file://")) return
return normalize(tabValue)
}
const inflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{
file: Record<string, FileState>
}>({
file: {},
})
const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const [view, setView, _, ready] = persisted(
Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
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
setStore("file", path, { path, name: getFilename(path) })
}
function load(input: string, options?: { force?: boolean }) {
const path = normalize(input)
if (!path) return Promise.resolve()
ensure(path)
const current = store.file[path]
if (!options?.force && current?.loaded) return Promise.resolve()
const pending = inflight.get(path)
if (pending) return pending
setStore(
"file",
path,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
const promise = sdk.client.file
.read({ path })
.then((x) => {
setStore(
"file",
path,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.content = x.data
}),
)
})
.catch((e) => {
setStore(
"file",
path,
produce((draft) => {
draft.loading = false
draft.error = e.message
}),
)
showToast({
variant: "error",
title: "Failed to load file",
description: e.message,
})
})
.finally(() => {
inflight.delete(path)
})
inflight.set(path, promise)
return promise
}
const stop = sdk.event.listen((e) => {
const event = e.details
if (event.type !== "file.watcher.updated") return
const path = normalize(event.properties.file)
if (!path) return
if (path.startsWith(".git/")) return
if (!store.file[path]) return
load(path, { force: true })
})
const get = (input: string) => store.file[normalize(input)]
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
const setScrollTop = (input: string, top: number) => {
const path = normalize(input)
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
pruneView(path)
}
const setScrollLeft = (input: string, left: number) => {
const path = normalize(input)
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
pruneView(path)
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
const path = normalize(input)
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
pruneView(path)
}
onCleanup(() => stop())
return {
ready,
normalize,
tab,
pathFromTab,
get,
load,
scrollTop,
scrollLeft,
setScrollTop,
setScrollLeft,
selectedLines,
setSelectedLines,
searchFiles: (query: string) =>
sdk.client.find.files({ query, dirs: "false" }).then((x) => (x.data ?? []).map(normalize)),
searchFilesAndDirectories: (query: string) =>
sdk.client.find.files({ query, dirs: "true" }).then((x) => (x.data ?? []).map(normalize)),
}
},
})

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,19 +19,83 @@ 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({
baseUrl: server.url,
signal: AbortSignal.timeout(1000 * 60 * 10),
fetch: platform.fetch,
throwOnError: true,
})

View File

@@ -15,7 +15,7 @@ import {
type McpStatus,
type LspStatus,
type VcsInfo,
type Permission,
type PermissionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -23,12 +23,12 @@ 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"
type State = {
ready: boolean
status: "loading" | "partial" | "complete"
agent: Agent[]
command: Command[]
project: string
@@ -46,7 +46,7 @@ type State = {
[sessionID: string]: Todo[]
}
permission: {
[sessionID: string]: Permission[]
[sessionID: string]: PermissionRequest[]
}
mcp: {
[name: string]: McpStatus
@@ -88,7 +88,7 @@ function createGlobalSync() {
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
ready: false,
status: "loading" as const,
agent: [],
command: [],
session: [],
@@ -115,13 +115,14 @@ function createGlobalSync() {
.then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.slice()
.filter((s) => !s.time.archived)
.sort((a, b) => a.id.localeCompare(b.id))
// Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => {
if (i < store.limit) return true
const updated = new Date(s.time.updated).getTime()
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
return updated > fourHoursAgo
})
setStore("session", reconcile(sessions, { key: "id" }))
@@ -141,7 +142,8 @@ function createGlobalSync() {
directory,
throwOnError: true,
})
const load = {
const blockingRequests = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () =>
sdk.provider.list().then((x) => {
@@ -156,51 +158,61 @@ function createGlobalSync() {
})),
})
}),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
permission: () =>
sdk.permission.list().then((x) => {
const grouped: Record<string, Permission[]> = {}
for (const perm of x.data ?? []) {
const existing = grouped[perm.sessionID]
if (existing) {
existing.push(perm)
continue
}
grouped[perm.sessionID] = [perm]
}
batch(() => {
for (const sessionID of Object.keys(store.permission)) {
if (grouped[sessionID]) continue
setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
setStore(
"permission",
sessionID,
reconcile(
permissions.slice().sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
}
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true))
await Promise.all(Object.values(blockingRequests).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
Promise.all([
sdk.path.get().then((x) => setStore("path", x.data!)),
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.session.status().then((x) => setStore("session_status", x.data!)),
loadSessions(directory),
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.vcs.get().then((x) => setStore("vcs", x.data)),
sdk.permission.list().then((x) => {
const grouped: Record<string, PermissionRequest[]> = {}
for (const perm of x.data ?? []) {
if (!perm?.id || !perm.sessionID) continue
const existing = grouped[perm.sessionID]
if (existing) {
existing.push(perm)
continue
}
grouped[perm.sessionID] = [perm]
}
batch(() => {
for (const sessionID of Object.keys(store.permission)) {
if (grouped[sessionID]) continue
setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
setStore(
"permission",
sessionID,
reconcile(
permissions
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
setStore("status", "complete")
})
})
.catch((e) => setGlobalStore("error", e))
}
globalSDK.event.listen((e) => {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -344,7 +356,7 @@ function createGlobalSync() {
setStore("vcs", { branch: event.properties.branch })
break
}
case "permission.updated": {
case "permission.asked": {
const sessionID = event.properties.sessionID
const permissions = store.permission[sessionID]
if (!permissions) {
@@ -370,7 +382,7 @@ function createGlobalSync() {
case "permission.replied": {
const permissions = store.permission[event.properties.sessionID]
if (!permissions) break
const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
if (!result.found) break
setStore(
"permission",
@@ -392,6 +404,7 @@ function createGlobalSync() {
}
}
})
onCleanup(unsub)
async function bootstrap() {
const health = await globalSDK.client.global
@@ -414,10 +427,12 @@ function createGlobalSync() {
),
retry(() =>
globalSDK.client.project.list().then(async (x) => {
setGlobalStore(
"project",
x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
)
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
setGlobalStore("project", projects)
}),
),
retry(() =>

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]
@@ -28,8 +30,15 @@ type SessionTabs = {
all: string[]
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type ReviewDiffStyle = "unified" | "split"
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -37,7 +46,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync()
const server = useServer()
const [store, setStore, _, ready] = persisted(
"layout.v4",
Persist.global("layout", ["layout.v6"]),
createStore({
sidebar: {
opened: false,
@@ -49,14 +58,134 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
review: {
opened: true,
diffStyle: "split" as ReviewDiffStyle,
},
session: {
width: 600,
},
mobileSidebar: {
opened: false,
},
sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
}),
)
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 {
@@ -66,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 },
},
]
@@ -87,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))
@@ -103,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)
@@ -156,6 +323,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
review: {
opened: createMemo(() => store.review?.opened ?? true),
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) {
setStore("review", { opened: true, diffStyle })
return
}
setStore("review", "diffStyle", diffStyle)
},
open() {
setStore("review", "opened", true)
},
@@ -171,12 +346,51 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
resize(width: number) {
if (!store.session) {
setStore("session", { width })
} else {
setStore("session", "width", width)
return
}
setStore("session", "width", width)
},
},
mobileSidebar: {
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
show() {
setStore("mobileSidebar", "opened", true)
},
hide() {
setStore("mobileSidebar", "opened", false)
},
toggle() {
setStore("mobileSidebar", "opened", (x) => !x)
},
},
view(sessionKey: string) {
touch(sessionKey)
scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
return {
scroll(tab: string) {
return scroll.scroll(sessionKey, tab)
},
setScroll(tab: string, pos: SessionScroll) {
scroll.setScroll(sessionKey, tab, pos)
},
review: {
open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
return
}
if (same(current.reviewOpen, open)) return
setStore("sessionView", sessionKey, "reviewOpen", open)
},
},
}
},
tabs(sessionKey: string) {
touch(sessionKey)
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return {
tabs,
@@ -198,38 +412,55 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
async open(tab: string) {
const current = store.sessionTabs[sessionKey] ?? { all: [] }
if (tab !== "review") {
if (!current.all.includes(tab)) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
} else {
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
setStore("sessionTabs", sessionKey, "active", tab)
}
if (tab === "review") {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: tab })
return
}
}
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: tab })
} else {
setStore("sessionTabs", sessionKey, "active", tab)
return
}
if (tab === "context") {
const all = [tab, ...current.all.filter((x) => x !== tab)]
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all, active: tab })
return
}
setStore("sessionTabs", sessionKey, "all", all)
setStore("sessionTabs", sessionKey, "active", tab)
return
}
if (!current.all.includes(tab)) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
return
}
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
setStore("sessionTabs", sessionKey, "active", tab)
return
}
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: current.all, active: tab })
return
}
setStore("sessionTabs", sessionKey, "active", tab)
},
close(tab: string) {
const current = store.sessionTabs[sessionKey]
if (!current) return
const all = current.all.filter((x) => x !== tab)
batch(() => {
setStore(
"sessionTabs",
sessionKey,
"all",
current.all.filter((x) => x !== tab),
)
if (current.active === tab) {
const index = current.all.findIndex((f) => f === tab)
const previous = current.all[Math.max(0, index - 1)]
setStore("sessionTabs", sessionKey, "active", previous)
}
setStore("sessionTabs", sessionKey, "all", all)
if (current.active !== tab) return
const index = current.all.findIndex((f) => f === tab)
const next = all[index - 1] ?? all[0]
setStore("sessionTabs", sessionKey, "active", next)
})
},
move(tab: string, to: number) {

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")
@@ -430,7 +437,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
// ]
// })
// setStore("active", relativePath)
context.addActive()
// context.addActive()
if (options?.pinned) setStore("node", path, "pinned", true)
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
if (store.node[relativePath]?.loaded) return
@@ -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) => {
@@ -538,66 +546,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})()
const context = (() => {
const [store, setStore] = createStore<{
activeTab: boolean
files: string[]
activeFile?: string
items: (ContextItem & { key: string })[]
}>({
activeTab: true,
files: [],
items: [],
})
const files = createMemo(() => store.files.map((x) => file.node(x)))
const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
return {
all() {
return store.items
},
// active() {
// return store.activeTab ? file.active() : undefined
// },
addActive() {
setStore("activeTab", true)
},
removeActive() {
setStore("activeTab", false)
},
add(item: ContextItem) {
let key = item.type
switch (item.type) {
case "file":
key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
break
}
if (store.items.find((x) => x.key === key)) return
setStore("items", (x) => [...x, { key, ...item }])
},
remove(key: string) {
setStore("items", (x) => x.filter((x) => x.key !== key))
},
files,
openFile(path: string) {
file.init(path).then(() => {
setStore("files", (x) => [...x, path])
setStore("activeFile", path)
})
},
activeFile,
setActiveFile(path: string | undefined) {
setStore("activeFile", path)
},
}
})()
const result = {
slug: createMemo(() => base64Encode(sdk.directory)),
model,
agent,
file,
context,
}
return result
},

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,130 +1,167 @@
import { createEffect, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { Permission } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist"
type PermissionsBySession = {
[sessionID: string]: Permission[]
}
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
type PermissionRespondFn = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
directory?: string
}) => void
const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
function shouldAutoAccept(perm: PermissionRequest) {
return perm.permission === "edit"
}
function shouldAutoAccept(perm: Permission) {
return AUTO_ACCEPT_TYPES.has(perm.type)
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: (props: { permissions: PermissionsBySession; onRespond: PermissionRespondFn }) => {
init: () => {
const params = useParams()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => {
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.v1",
Persist.global("permission", ["permission.v3"]),
createStore({
autoAcceptEdits: {} as Record<string, boolean>,
}),
)
const responded = new Set<string>()
const watches = new Map<string, () => void>()
function respond(perm: Permission) {
if (responded.has(perm.id)) return
responded.add(perm.id)
props.onRespond({
sessionID: perm.sessionID,
permissionID: perm.id,
response: "once",
const respond: PermissionRespondFn = (input) => {
globalSDK.client.permission.respond(input).catch(() => {
responded.delete(input.permissionID)
})
}
function watch(sessionID: string) {
if (watches.has(sessionID)) return
function respondOnce(permission: PermissionRequest, directory?: string) {
if (responded.has(permission.id)) return
responded.add(permission.id)
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
const dispose = createRoot((dispose) => {
createEffect(() => {
if (!store.autoAcceptEdits[sessionID]) return
function acceptKey(sessionID: string, directory?: string) {
if (!directory) return sessionID
return `${base64Encode(directory)}/${sessionID}`
}
const permissions = props.permissions[sessionID] ?? []
permissions.length
function isAutoAccepting(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
}
for (const perm of permissions) {
const unsubscribe = globalSDK.event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
const perm = event.properties
if (!isAutoAccepting(perm.sessionID, e.name)) return
if (!shouldAutoAccept(perm)) return
respondOnce(perm, e.name)
})
onCleanup(unsubscribe)
function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAcceptEdits[key] = true
delete draft.autoAcceptEdits[sessionID]
}),
)
globalSDK.client.permission
.list({ directory })
.then((x) => {
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (perm.sessionID !== sessionID) continue
if (!shouldAutoAccept(perm)) continue
respond(perm)
respondOnce(perm, directory)
}
})
return dispose
})
watches.set(sessionID, dispose)
.catch(() => undefined)
}
function unwatch(sessionID: string) {
const dispose = watches.get(sessionID)
if (!dispose) return
dispose()
watches.delete(sessionID)
}
createEffect(() => {
if (!ready()) return
for (const sessionID in store.autoAcceptEdits) {
if (!store.autoAcceptEdits[sessionID]) continue
watch(sessionID)
}
})
onCleanup(() => {
for (const dispose of watches.values()) dispose()
watches.clear()
})
function enable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, true)
watch(sessionID)
const permissions = props.permissions[sessionID] ?? []
for (const perm of permissions) {
if (!shouldAutoAccept(perm)) continue
respond(perm)
}
}
function disable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, false)
unwatch(sessionID)
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 {
get permissions() {
return props.permissions
ready,
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
},
respond: props.onRespond,
isAutoAccepting(sessionID: string) {
return store.autoAcceptEdits[sessionID] ?? false
},
toggleAutoAccept(sessionID: string) {
if (store.autoAcceptEdits[sessionID]) {
disable(sessionID)
isAutoAccepting,
toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID)
enable(sessionID, directory)
},
enableAutoAccept(sessionID: string) {
if (store.autoAcceptEdits[sessionID]) return
enable(sessionID)
enableAutoAccept(sessionID: string, directory: string) {
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

@@ -2,8 +2,8 @@ import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { TextSelection } from "./local"
import { persisted } from "@/utils/persist"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
interface PartBase {
content: string
@@ -18,7 +18,12 @@ export interface TextPart extends PartBase {
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: TextSelection
selection?: FileSelection
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
@@ -29,11 +34,27 @@ export interface ImageAttachmentPart {
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
}
export type ContextItem = FileContextItem
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
@@ -43,7 +64,13 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
return false
}
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
if (partA.type === "file") {
const fileA = partA as FileAttachmentPart
const fileB = partB as FileAttachmentPart
if (fileA.path !== fileB.path) return false
if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
}
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
return false
}
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
@@ -53,7 +80,7 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
return true
}
function cloneSelection(selection?: TextSelection) {
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
@@ -61,6 +88,7 @@ function cloneSelection(selection?: TextSelection) {
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
@@ -75,24 +103,57 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
name: "Prompt",
init: () => {
const params = useParams()
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
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
context: {
activeTab: boolean
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
activeTab: true,
items: [],
},
}),
)
function keyForItem(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
return `${item.type}:${item.path}:${start}:${end}`
}
return {
ready,
current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
context: {
activeTab: createMemo(() => store.context.activeTab),
items: createMemo(() => store.context.items),
addActive() {
setStore("context", "activeTab", true)
},
removeActive() {
setStore("context", "activeTab", false)
},
add(item: ContextItem) {
const key = keyForItem(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {

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"
@@ -11,7 +12,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const globalSDK = useGlobalSDK()
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
signal: AbortSignal.timeout(1000 * 60 * 10),
fetch: platform.fetch,
directory: props.directory,
throwOnError: true,
@@ -21,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(2000),
})
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

@@ -18,8 +18,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return {
data: store,
set: setStore,
get status() {
return store.status
},
get ready() {
return store.ready
return store.status !== "loading"
},
get project() {
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
@@ -56,14 +59,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
draft.part[input.messageID] = input.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
}),
)
},
async sync(sessionID: string, _isRetry = false) {
const [session, messages, todo, diff] = await Promise.all([
retry(() => sdk.client.session.get({ sessionID })),
retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })),
])
@@ -88,6 +94,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
reconcile(
(messages.data ?? [])
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
@@ -95,11 +102,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
)
for (const message of messages.data ?? []) {
if (!message?.info?.id) continue
setStore(
"part",
message.info.id,
reconcile(
message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@@ -112,6 +123,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setStore("limit", (x) => x + count)
await sdk.client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, store.limit)

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,15 +1,16 @@
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"
import { PermissionProvider } from "@/context/permission"
import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context"
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,12 +27,19 @@ 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 (
<PermissionProvider permissions={sync.data.permission} onRespond={respond}>
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
</PermissionProvider>
<DataProvider
data={sync.data}
directory={directory()}
onPermissionRespond={respond}
onNavigateToSession={navigateToSession}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
})}
</SyncProvider>

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

@@ -22,10 +22,11 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Mark } from "@opencode-ai/ui/logo"
import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client"
@@ -44,8 +45,9 @@ import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
@@ -60,17 +62,9 @@ export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
lastSession: {} as { [directory: string]: string },
activeDraggable: undefined as string | undefined,
mobileSidebarOpen: false,
mobileProjectsExpanded: {} as Record<string, boolean>,
})
const mobileSidebar = {
open: () => store.mobileSidebarOpen,
show: () => setStore("mobileSidebarOpen", true),
hide: () => setStore("mobileSidebarOpen", false),
toggle: () => setStore("mobileSidebarOpen", (x) => !x),
}
const mobileProjects = {
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
@@ -91,6 +85,7 @@ export default function Layout(props: ParentProps) {
const platform = usePlatform()
const server = useServer()
const notification = useNotification()
const permission = usePermission()
const navigate = useNavigate()
const providers = useProviders()
const dialog = useDialog()
@@ -131,11 +126,15 @@ export default function Layout(props: ParentProps) {
})
}
onMount(async () => {
if (platform.checkUpdate && platform.update && platform.restart) {
const { updateAvailable, version } = await platform.checkUpdate()
if (updateAvailable) {
showToast({
onMount(() => {
if (!platform.checkUpdate || !platform.update || !platform.restart) return
let toastId: number | undefined
async function pollUpdate() {
const { updateAvailable, version } = await platform.checkUpdate!()
if (updateAvailable && toastId === undefined) {
toastId = showToast({
persistent: true,
icon: "download",
title: "Update available",
@@ -156,31 +155,48 @@ export default function Layout(props: ParentProps) {
})
}
}
pollUpdate()
const interval = setInterval(pollUpdate, 10 * 60 * 1000)
onCleanup(() => clearInterval(interval))
})
onMount(() => {
const seenSessions = new Set<string>()
const toastBySession = new Map<string, number>()
const alertedAtBySession = new Map<string, number>()
const permissionAlertCooldownMs = 5000
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type !== "permission.updated") return
if (e.details?.type !== "permission.asked") return
const directory = e.name
const permission = e.details.properties
const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
const perm = e.details.properties
if (permission.autoResponds(perm, directory)) return
const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === permission.sessionID)
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)
const description = `${sessionTitle} in ${projectName} needs permission`
const href = `/${base64Encode(directory)}/session/${permission.sessionID}`
const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
const now = Date.now()
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
if (now - lastAlerted < permissionAlertCooldownMs) return
alertedAtBySession.set(sessionKey, now)
void platform.notify("Permission required", description, href)
if (directory === currentDir && permission.sessionID === currentSession) return
const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
if (directory === currentDir && perm.sessionID === currentSession) return
if (directory === currentDir && session?.parentID === currentSession) return
const sessionKey = `${directory}:${permission.sessionID}`
if (seenSessions.has(sessionKey)) return
seenSessions.add(sessionKey)
const existingToastId = toastBySession.get(sessionKey)
if (existingToastId !== undefined) {
toaster.dismiss(existingToastId)
}
const toastId = showToast({
persistent: true,
@@ -213,7 +229,7 @@ export default function Layout(props: ParentProps) {
if (toastId !== undefined) {
toaster.dismiss(toastId)
toastBySession.delete(sessionKey)
seenSessions.delete(sessionKey)
alertedAtBySession.delete(sessionKey)
}
const [store] = globalSync.child(currentDir)
const childSessions = store.session.filter((s) => s.parentID === currentSession)
@@ -223,7 +239,7 @@ export default function Layout(props: ParentProps) {
if (childToastId !== undefined) {
toaster.dismiss(childToastId)
toastBySession.delete(childKey)
seenSessions.delete(childKey)
alertedAtBySession.delete(childKey)
}
}
})
@@ -250,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]
@@ -296,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))
}
@@ -444,13 +466,13 @@ export default function Layout(props: ParentProps) {
if (!directory) return
const lastSession = store.lastSession[directory]
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
mobileSidebar.hide()
layout.mobileSidebar.hide()
}
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session?.id}`)
mobileSidebar.hide()
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
layout.mobileSidebar.hide()
}
function openProject(directory: string, navigate = true) {
@@ -498,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))
})
@@ -628,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
@@ -642,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
@@ -708,17 +730,13 @@ export default function Layout(props: ParentProps) {
</A>
</Tooltip>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
<Tooltip
<TooltipKeybind
placement={props.mobile ? "bottom" : "right"}
value={
<div class="flex items-center gap-2">
<span>Archive session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
</div>
}
title="Archive session"
keybind={command.keybind("session.archive")}
>
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</Tooltip>
</TooltipKeybind>
</div>
</div>
</>
@@ -728,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 () => {
@@ -741,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)
@@ -759,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
@@ -786,24 +818,21 @@ export default function Layout(props: ParentProps) {
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
</Tooltip>
<TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`${defaultWorktree()}/session`} icon="plus-small" variant="ghost" />
</TooltipKeybind>
</div>
</Button>
<Collapsible.Content>
<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}>
@@ -815,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">
@@ -871,75 +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}>
<Tooltip
class="shrink-0"
placement="right"
value={
<div class="flex items-center gap-2">
<span>Toggle sidebar</span>
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
</div>
}
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>
</Tooltip>
</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>
@@ -1012,27 +1051,29 @@ export default function Layout(props: ParentProps) {
</Button>
</Tooltip>
</div>
</>
</div>
)
}
return (
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Header
navigateToProject={navigateToProject}
navigateToSession={navigateToSession}
onMobileMenuToggle={mobileSidebar.toggle}
/>
<div class="flex-1 min-h-0 flex">
<div
classList={{
"hidden xl:flex": true,
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
"flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
"hidden xl:block": true,
"relative shrink-0": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
>
<div
classList={{
"@container w-full h-full pb-5 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
}}
>
<SidebarContent />
</div>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
@@ -1044,27 +1085,35 @@ export default function Layout(props: ParentProps) {
onCollapse={layout.sidebar.close}
/>
</Show>
<SidebarContent />
</div>
<div class="xl:hidden">
<div
classList={{
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": mobileSidebar.open(),
"opacity-0 pointer-events-none": !mobileSidebar.open(),
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) mobileSidebar.hide()
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<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,
"translate-x-0": mobileSidebar.open(),
"-translate-x-full": !mobileSidebar.open(),
"@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>

File diff suppressed because it is too large Load Diff

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.218",
"version": "1.1.6",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",

View File

@@ -7,10 +7,10 @@ export const config = {
// GitHub
github: {
repoUrl: "https://github.com/sst/opencode",
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "41K",
full: "41,000",
compact: "50K",
full: "50,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "450",
commits: "6,000",
monthlyUsers: "400,000",
contributors: "500",
commits: "6,500",
monthlyUsers: "650,000",
},
} as const

View File

@@ -26,7 +26,7 @@ export default function NotFound() {
<a href="/docs">Docs</a>
</div>
<div data-slot="action">
<a href="https://github.com/sst/opencode">GitHub</a>
<a href="https://github.com/anomalyco/opencode">GitHub</a>
</div>
<div data-slot="action">
<a href="/discord">Discord</a>

View File

@@ -21,7 +21,7 @@ export async function GET({ params: { platform } }: APIEvent) {
const assetName = assetNames[platform]
if (!assetName) return new Response("Not Found", { status: 404 })
const resp = await fetch(`https://github.com/sst/opencode/releases/latest/download/${assetName}`, {
const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, {
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 60 * 24,

View File

@@ -1,6 +1,6 @@
export async function GET() {
const response = await fetch(
"https://raw.githubusercontent.com/sst/opencode/refs/heads/dev/packages/sdk/openapi.json",
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/sdk/openapi.json",
)
const json = await response.json()
return json

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

@@ -151,7 +151,7 @@ export default function Home() {
<a href="https://x.com/opencode">X.com</a>
</div>
<div data-slot="cell">
<a href="https://github.com/sst/opencode">GitHub</a>
<a href="https://github.com/anomalyco/opencode">GitHub</a>
</div>
<div data-slot="cell">
<a href="https://opencode.ai/discord">Discord</a>

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

@@ -1,5 +1,5 @@
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, Show } from "solid-js"
import { createEffect, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
@@ -68,6 +68,12 @@ export function ReloadSection() {
reloadTrigger: "",
})
const processingFee = createMemo(() => {
const reloadAmount = billingInfo()?.reloadAmount
if (!reloadAmount) return "0.00"
return (((reloadAmount + 0.3) / 0.956) * 0.044 + 0.3).toFixed(2)
})
createEffect(() => {
if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
setStore("show", false)
@@ -104,8 +110,8 @@ export function ReloadSection() {
}
>
<p>
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+$1.23 processing fee)
when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
</p>
</Show>
<button data-color="primary" type="button" onClick={() => show()}>

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()`)),
),
),

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