Compare commits

...
211 Commits
Author SHA1 Message Date
opencode bd9c13bb27 release: v1.1.8
Update Nix Hashes / update (push) Has been cancelled
2026-01-08 23:58:57 +00:00
Adam 3019b1f825 Revert "fix(desktop): open external links in default browser (#7221)"
This reverts commit 361a962673.
2026-01-08 17:51:30 -06:00
Anthony HuangandAnthony Huang 119cc8c795 feat(desktop): add Vesper theme (#7331)
Co-authored-by: Anthony Huang <ahuang@ramp.com>
2026-01-08 17:48:57 -06:00
Adam be9b2bab15 feat(app): cache session-scoped stores, optional context gating 2026-01-08 17:48:15 -06:00
Adam c949e5b390 feat(app): incrementally render turns, markdown cache, lazily render diffs 2026-01-08 17:48:15 -06:00
Adam 1c717d62e4 feat(app): gate hydration side-effects, handoff while stores load 2026-01-08 17:48:15 -06:00
Adam 27675dfd70 feat(app): prefetch adjacent sessions 2026-01-08 17:48:15 -06:00
Adam 374275eeb6 feat(app): chunk message loading, lazy load diffs 2026-01-08 17:48:15 -06:00
AJ Ka faa848cfb1 Fix for #7229 Fix/project sidebar folder name (#7232) 2026-01-08 17:47:15 -06:00
Dax Raad 80772e5ac2 ci 2026-01-08 18:26:51 -05:00
Dax Raad be34747526 add sponsored 2026-01-08 18:21:21 -05:00
Github Action 49d9f99924 Update Nix flake.lock and hashes 2026-01-08 22:56:55 +00:00
Sebastian Herrlinger 1f9e195cd8 stop esc propagation from dialogs 2026-01-08 23:53:00 +01:00
Sebastian Herrlinger 539d6baa8c upgrade opentui to v0.1.70 2026-01-08 23:52:36 +01:00
Aleksandr BagatkaandAiden Cline f6fc693c1f fix(ui): use full file path for fuzzy matching in autocomplete (#6705)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-08 16:43:05 -06:00
GitHub Action 50d8396c9a chore: generate 2026-01-08 22:33:10 +00:00
Dax 22dd70b75b feat(question): support multi-select questions (#7386) 2026-01-08 17:32:21 -05:00
GitHub Action b4f8de0c0a chore: generate 2026-01-08 22:03:23 +00:00
Marc Espin 768e0553bd fix(docs): Document cargofmt (#7383) 2026-01-08 16:00:03 -06:00
opencode f3e79235fb release: v1.1.7
Update Nix Hashes / update (push) Has been cancelled
2026-01-08 20:37:24 +00:00
ryanwyler eacf3ad361 fix(tui): restore showDetails check removed in Permission rework (#7285) 2026-01-08 14:30:41 -06:00
Mani Sundararajan 72062d22a0 fix: cleanly restore terminal state on fatal error exit (#7359) 2026-01-08 14:22:24 -06:00
Aiden Cline 9f90f0e8ed ci: update fork_repo var 2026-01-08 14:19:44 -06:00
Aiden Cline c74bc323b6 ci: tweak enforcement of titles 2026-01-08 14:12:38 -06:00
zerone0x c7b825a42a fix(app): show custom models without valid release_date in web UI mod… (#7349) 2026-01-08 14:06:18 -06:00
Dax Raad b1a613b3b9 ci: add retry logic to desktop build to handle intermittent failures 2026-01-08 14:57:55 -05:00
Noam Bresslerandnoam-v 958f1edfef fix: Add kind, title, rawInput to ACP tool_call_update events (#7368)
Co-authored-by: noam-v <noam@bespo.ai>
2026-01-08 13:56:16 -06:00
Dax 2bb299d741 tui: improve responsive layout by hiding header/footer when sidebar is visible (#7365) 2026-01-08 13:57:23 -05:00
Aiden Cline 9930ac6929 ci: adjust workflow 2026-01-08 12:50:17 -06:00
Aiden Cline 1906a347f3 docs: update pr workflows 2026-01-08 12:44:04 -06:00
Dax Raad e5d0c63b29 docs: simplify tools configuration by consolidating under permission field 2026-01-08 12:39:40 -05:00
Aiden Cline 970796b832 docs: add PR title guidelines and workflow to enforce conventional commits 2026-01-08 11:23:52 -06:00
Aiden Cline 3c5043497c ignore: ensure new file truncation stuff still works even if external_directoy is set to deny 2026-01-08 11:07:33 -06:00
Max Stevens 4d09c5618e refactor: session settings simplification (#7342) 2026-01-08 11:06:06 -06:00
Sercan Sagman adae0d1853 fix(cli): add help text to debug command and subcommands (#7328)
Signed-off-by: assagman <ahmetsercansagman@gmail.com>
2026-01-08 09:42:12 -06:00
Aaron Iker 61aeb2a2a7 feat: Polish dialog, popover shadows & borders, add missing provider logos (#7326) 2026-01-08 09:02:04 -06:00
GitHub Action 4b0f7b82ba ignore: update download stats 2026-01-08 2026-01-08 12:05:02 +00:00
GitHub Action 9fb24074c8 chore: generate 2026-01-08 06:11:00 +00:00
freespace8 542c9d5346 feat(flags): allow disabling .claude prompt and skills loading (#7205) 2026-01-08 00:10:25 -06:00
Aiden Cline d5f0e3fccc test: add larger image test for read tool 2026-01-07 23:01:02 -06:00
Dax Raad 7d2bb5cb2b tui: remove unused imports and variables from header component to clean up code 2026-01-07 23:54:54 -05:00
Dax ca7a70b628 tui: add overlay sidebar for narrow screens to improve mobile experience (#7288) 2026-01-07 23:51:49 -05:00
Dax Raad b3a2f9fb4e tui: add expandable bash output for long commands to improve readability 2026-01-07 23:11:46 -05:00
Dax Raad 8be5a29870 tui: remove username visibility toggle and simplify user message metadata display 2026-01-07 23:02:19 -05:00
Dax Raad 68092f22e1 tui: improve other answer option layout with better indentation and clearer label 2026-01-07 22:34:31 -05:00
GitHub Action 83f3c729e9 chore: generate 2026-01-08 03:30:18 +00:00
Dax e37fd9c105 core: add interactive question tool for gathering user preferences and clarifying instructions (#7268) 2026-01-07 22:29:42 -05:00
Aiden Cline 2e4fe973c9 fix: issue w/ normal transform options conflicting w/ small model options when gen-ing title 2026-01-07 17:32:38 -06:00
Aiden Cline 1b82511fbd feat: write truncated tool outputs to files (#7239) 2026-01-07 17:25:00 -06:00
Ariane Emory f24314438b fix(tui): ensure forked message text is inserted in prompt (resolves #7257) (#7259) 2026-01-07 16:12:03 -06:00
Andrew Thal 361a962673 fix(desktop): open external links in default browser (#7221) 2026-01-07 16:04:27 -06:00
Adam fa9c283fcf fix(app): user message text wrap 2026-01-07 15:36:08 -06:00
Frank 947b864d96 wip: zen 2026-01-07 15:49:52 -05:00
Ariane Emory 03eabb10e4 fix: use selectedForeground's computer colour (or theme's selectedForeground value) for the colour of text in permission selection (resolves #7246) (#7251) 2026-01-07 14:45:30 -06:00
M. Adel AlhashemiandAiden Cline 34c9d106ee refactor: simplify task tool subagent filtering (#7165)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-07 13:28:13 -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
Update Nix Hashes / update (push) Has been cancelled
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
Update Nix Hashes / update (push) Has been cancelled
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
jwaterwaterandClaude 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
shuvandshuv 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 VaziryandBrendan Allan 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
usvimalandBrendan Allan 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
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
Update Nix Hashes / update (push) Has been cancelled
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.TangandTang Guofang 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
Update Nix Hashes / update (push) Has been cancelled
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 MucukandMelih 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)
publish-github-action / publish (push) Has been cancelled
Update Nix Hashes / update (push) Has been cancelled
2026-01-05 13:00:22 -06:00
Ravi KumarandAiden Cline 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 PolitoandBrendan Allan 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 HillandDax 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
252 changed files with 52362 additions and 4888 deletions
+2
View File
@@ -44,6 +44,8 @@ jobs:
{ {
echo "Check for duplicate PRs related to this new PR:" echo "Check for duplicate PRs related to this new PR:"
echo "" echo ""
echo "CURRENT_PR_NUMBER: $PR_NUMBER"
echo ""
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)" echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
echo "" echo ""
echo "Description:" echo "Description:"
+139
View File
@@ -0,0 +1,139 @@
name: PR Standards
on:
pull_request_target:
types: [opened, edited, synchronize]
jobs:
check-standards:
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: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Check PR standards
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const title = pr.title;
async function addLabel(label) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label]
});
}
async function removeLabel(label) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label
});
} catch (e) {
// Label wasn't present, ignore
}
}
async function comment(marker, body) {
const markerText = `<!-- pr-standards:${marker} -->`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const existing = comments.find(c => c.body.includes(markerText));
if (existing) return;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: markerText + '\n' + body
});
}
// Step 1: Check title format
// Matches: feat:, feat(scope):, feat (scope):, etc.
const titlePattern = /^(feat|fix|docs|chore|refactor|test)\s*(\([a-zA-Z0-9-]+\))?\s*:/;
const hasValidTitle = titlePattern.test(title);
if (!hasValidTitle) {
await addLabel('needs:title');
await comment('title', `Hey! Your PR title \`${title}\` doesn't follow conventional commit format.
Please update it to start with one of:
- \`feat:\` or \`feat(scope):\` new feature
- \`fix:\` or \`fix(scope):\` bug fix
- \`docs:\` or \`docs(scope):\` documentation changes
- \`chore:\` or \`chore(scope):\` maintenance tasks
- \`refactor:\` or \`refactor(scope):\` code refactoring
- \`test:\` or \`test(scope):\` adding or updating tests
Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`).
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`);
return;
}
await removeLabel('needs:title');
// Step 2: Check for linked issue (skip for docs/refactor PRs)
const skipIssueCheck = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
if (skipIssueCheck) {
await removeLabel('needs:issue');
console.log('Skipping issue check for docs/refactor PR');
return;
}
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 1) {
totalCount
}
}
}
}
`;
const result = await github.graphql(query, {
owner: context.repo.owner,
repo: context.repo.repo,
number: pr.number
});
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
if (linkedIssues === 0) {
await addLabel('needs:issue');
await comment('issue', `Thanks for your contribution!
This PR doesn't have a linked issue. All PRs must reference an existing issue.
Please:
1. Open an issue describing the bug/feature (if one doesn't exist)
2. Add \`Fixes #<number>\` or \`Closes #<number>\` to this PR description
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`);
return;
}
await removeLabel('needs:issue');
console.log('PR meets all standards');
+16 -12
View File
@@ -177,8 +177,22 @@ jobs:
cargo tauri --version cargo tauri --version
- name: Build and upload artifacts - name: Build and upload artifacts
timeout-minutes: 20 uses: Wandalen/wretry.action@v3
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a timeout-minutes: 60
with:
attempt_limit: 3
attempt_delay: 10000
action: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
with: |
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.release }}
tagName: ${{ needs.publish.outputs.tag }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
@@ -190,16 +204,6 @@ jobs:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
with:
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.release }}
tagName: ${{ needs.publish.outputs.tag }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
publish-release: publish-release:
needs: needs:
+1 -1
View File
@@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs: jobs:
stats: stats:
if: github.repository == 'sst/opencode' if: github.repository == 'anomalyco/opencode'
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: permissions:
contents: write contents: write
+3 -1
View File
@@ -12,6 +12,8 @@ You are a duplicate PR detection agent. When a PR is opened, your job is to sear
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature. 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. Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
If you find potential duplicates: If you find potential duplicates:
@@ -19,6 +21,6 @@ If you find potential duplicates:
- List them with their titles and URLs - List them with their titles and URLs
- Briefly explain why they might be related - Briefly explain why they might be related
If no duplicates are found, say so clearly. 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. Keep your response concise and actionable.
+5
View File
@@ -45,6 +45,11 @@ export default tool({
} }
const prs = result.items as PR[] 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") 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}` return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
+75 -5
View File
@@ -83,12 +83,30 @@ This starts a local dev server at http://localhost:5173 (or similar port shown i
### Running the Desktop App ### Running the Desktop App
The desktop app is a native Tauri application that wraps the web UI. To run it: 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 ```bash
bun run --cwd packages/desktop dev 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] > [!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. > 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.
@@ -131,11 +149,63 @@ With that said, you may want to try these methods, as they might work for you.
## Pull Request Expectations ## Pull Request Expectations
- Try to keep pull requests small and focused. ### Issue First Policy
- Link relevant issue(s) in the description
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
### General Requirements
- Keep pull requests small and focused
- Explain the issue and why your change fixes it - Explain the issue and why your change fixes it
- Avoid having verbose LLM generated PR descriptions - Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
- Before adding new functions or functionality, ensure that such behavior doesn't already exist elsewhere in the codebase.
### UI Changes
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
### Logic Changes
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
- What did you test?
- How can a reviewer reproduce/confirm the fix?
### No AI-Generated Walls of Text
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
- Write short, focused descriptions
- Explain what changed and why in your own words
- If you can't explain it briefly, your PR might be too large
### PR Titles
PR titles should follow conventional commit standards:
- `feat:` new feature or functionality
- `fix:` bug fix
- `docs:` documentation or README changes
- `chore:` maintenance tasks, dependency updates, etc.
- `refactor:` code refactoring without changing behavior
- `test:` adding or updating tests
You can optionally include a scope to indicate which package is affected:
- `feat(app):` feature in the app package
- `fix(desktop):` bug fix in the desktop package
- `chore(opencode):` maintenance in the opencode package
Examples:
- `docs: update contributing guidelines`
- `fix: resolve crash on startup`
- `feat: add dark mode support`
- `feat(app): add dark mode support`
- `fix(desktop): resolve crash on startup`
- `chore: bump dependency versions`
### Style Preferences ### Style Preferences
+3 -7
View File
@@ -28,7 +28,8 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # or bun/pnpm/yarn npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows scoop bucket add extras; scoop install extras/opencode # Windows
choco install 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 paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
@@ -70,8 +71,7 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
### Agents ### Agents
OpenCode includes two built-in agents you can switch between, OpenCode includes two built-in agents you can switch between with the `Tab` key.
you can switch between these using the `Tab` key.
- **build** - Default, full access agent for development work - **build** - Default, full access agent for development work
- **plan** - Read-only agent for analysis and code exploration - **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 focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients. - A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
#### What'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) **Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+116
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)
+2 -1
View File
@@ -28,7 +28,8 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows scoop bucket add extras; scoop install extras/opencode # Windows
choco install 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 paru -S opencode-bin # Arch Linux
mise use -g github:anomalyco/opencode # 任何作業系統 mise use -g github:anomalyco/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支 nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
+195 -189
View File
@@ -1,191 +1,197 @@
# Download Stats # Download Stats
| Date | GitHub Downloads | npm Downloads | Total | | Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ------------------- | ------------------- | ------------------- | | ---------- | -------------------- | ------------------- | -------------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) | | 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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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 | 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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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) | | 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-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-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) |
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
+2 -3
View File
@@ -1,9 +1,8 @@
## Style Guide ## Style Guide
- Try to keep things in one function unless composable or reusable - Try to keep things in one function unless composable or reusable
- DO NOT do unnecessary destructuring of variables - AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
- DO NOT use `else` statements unless necessary = obj` just reference it as obj.a and obj.b. this preserves context
- DO NOT use `try`/`catch` if it can be avoided
- AVOID `try`/`catch` where possible - AVOID `try`/`catch` where possible
- AVOID `else` statements - AVOID `else` statements
- AVOID using `any` type - AVOID using `any` type
+43 -35
View File
@@ -22,7 +22,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -173,9 +173,10 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
@@ -201,7 +202,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -230,7 +231,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -246,7 +247,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.1.2", "version": "1.1.8",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -276,7 +277,7 @@
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.15.1", "@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
@@ -285,8 +286,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.68", "@opentui/core": "0.1.70",
"@opentui/solid": "0.1.68", "@opentui/solid": "0.1.70",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -349,7 +350,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -369,7 +370,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.2", "version": "1.1.8",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.88.1", "@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -380,7 +381,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -393,7 +394,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -401,6 +402,7 @@
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3", "@solid-primitives/bounds": "0.1.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@@ -431,7 +433,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -442,7 +444,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -909,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=="], "@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/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=="], "@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
@@ -1093,7 +1097,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], "@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=="], "@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=="],
@@ -1197,21 +1201,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.68", "", { "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.68", "@opentui/core-darwin-x64": "0.1.68", "@opentui/core-linux-arm64": "0.1.68", "@opentui/core-linux-x64": "0.1.68", "@opentui/core-win32-arm64": "0.1.68", "@opentui/core-win32-x64": "0.1.68", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-SZz5qNO+2lJ8jDEoTSieyXH23t49myu6NetLex+xzqOf67XsU6QKlDcw5oMmc3zrKvETXhgbBvlSnbyJNQoBMg=="], "@opentui/core": ["@opentui/core@0.1.70", "", { "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.70", "@opentui/core-darwin-x64": "0.1.70", "@opentui/core-linux-arm64": "0.1.70", "@opentui/core-linux-x64": "0.1.70", "@opentui/core-win32-arm64": "0.1.70", "@opentui/core-win32-x64": "0.1.70", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6cPAlbCnaiUUtQtvZNpkr0Xv8AdVAgJuy2VAwIsDN1pIv0zMpa0ZG+mr7afCGygw1eeDRveefrjfgFAB1r0SVw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.68", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ipPX2gavBLVtw3d8L4ZPJDLlEwIjIRNdlNlxu07rqSEGSfxD5s29yc+33wLAlYXbmnJDajOqm0Dx6HnlY1Y9Fg=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.70", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM8EnvW1tOAXWnp2Iy2M82I+ViSmRwUagx3v1/ni6N8GCcw/3mE0C6eB3sVlYNXVMwBEgiKpWFn85RCe4+qXQw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.68", "", { "os": "darwin", "cpu": "x64" }, "sha512-9dW0S9HINnuVjvC9QLj+S+329H7qEBQQtyJ9WHpykemokiJ5k4rnuDkfws5FxgTHIf/ddoYYTyPoGCS7WN5gsQ=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.70", "", { "os": "darwin", "cpu": "x64" }, "sha512-XdBgW+em8J+YGSUpaKF8/NxPjikJygK3dIkeMAw5xQ2lt7jXKxeM5MMmN/V4MfK3pLMtO56rLJlXaLH/h50uQA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.68", "", { "os": "linux", "cpu": "arm64" }, "sha512-/el6TbSQriBUfPhIa6SBfCCc7tjU98Bnhf2+w0zKwQFBjf3F3kmnI42++YxedMGFmL7bRt3EUawGOkQRZZzFAg=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.70", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSVWNMSOx0Na0M0LCqtWCxeh4SuLSK5lg8ZwVzsEoimIAxh0snp9nRUo/Qi8yD9BP0DSDmXuM/B3ONtzFaf0dw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.68", "", { "os": "linux", "cpu": "x64" }, "sha512-9NzVI3GZzmICoIu3YhWBdkEt0KvY27m++tu/MqW+xb6fnvN74jZkRWzlgjTdM70obL4eUGQdvU08sDHgZjsIJw=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.70", "", { "os": "linux", "cpu": "x64" }, "sha512-WUrhukefMghcZ7sAjkxEy50vA6ii0X21xh7m8c4omXyYYfQXyDs25pNExB8cwoCrZEaC8RTlF4lRSNPIXsZKhA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.68", "", { "os": "win32", "cpu": "arm64" }, "sha512-wrAeotyotOplUjQVBSxOGA8GCr9FWXSd6xCEo1PEGo/NjuAOtvHmKoENzyFEP0GzFsjvoUOyy2dZb987oFAn9A=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.70", "", { "os": "win32", "cpu": "arm64" }, "sha512-p1K2VJXGmZqSV7mR61v7KJpT1Zth7DS99wEtaqqfK68OWt33K2XxLmGO0KD142R2JLfXu32NnRmBHxmVx8IjBA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.68", "", { "os": "win32", "cpu": "x64" }, "sha512-w0yBjvzs/oMIwVdWICL4XlUrfsPoVXd4+RDqiuu+Xi/zD0UgANSTRY2asXca+gPe5zPHLsxvz1bAG0Z7uGtmyw=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.70", "", { "os": "win32", "cpu": "x64" }, "sha512-G6b8te1twMeDhjg1oZa0IcUjhOJZFCSdlQt+q5gu5vVtjCrIwAn9o7m5EwNMPakc31pDWUZ7v0ktgv0Xw1AQVA=="],
"@opentui/solid": ["@opentui/solid@0.1.68", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.68", "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-S1oHvCQaY+gCQu2kiiksPIScP8i0FiDOlAlLjtfwcRlgeSjzT0wRwFkvoh4uVUPuAlyigox7vMCE3j04SYSGKg=="], "@opentui/solid": ["@opentui/solid@0.1.70", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.70", "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-8Cw/w4Of2OJhsFhcp/Wdj8cJRVaGvVsIiUoYiFtyToM01J4en0bg/vnbeZteyuZWeEtA4iz1/rSEQf7Dp+2FIQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1901,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=="], "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=="], "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -2405,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-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=="], "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=="],
@@ -2781,7 +2787,9 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "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=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -3383,6 +3391,8 @@
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="], "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=="], "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=="], "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=="],
@@ -3757,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=="], "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=="], "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=="], "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=="],
@@ -4057,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/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/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=="], "@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=="],
@@ -4403,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=="], "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=="], "utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
+1 -1
View File
@@ -82,7 +82,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
id-token: write id-token: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 1 fetch-depth: 1
+16
View File
@@ -76,6 +76,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
"checkout.session.completed", "checkout.session.completed",
"checkout.session.expired", "checkout.session.expired",
"charge.refunded", "charge.refunded",
"invoice.payment_succeeded",
"customer.created", "customer.created",
"customer.deleted", "customer.deleted",
"customer.updated", "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 = [ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"), new sst.Secret("ZEN_MODELS1"),
new sst.Secret("ZEN_MODELS2"), new sst.Secret("ZEN_MODELS2"),
@@ -106,6 +120,7 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS6"), new sst.Secret("ZEN_MODELS6"),
new sst.Secret("ZEN_MODELS7"), new sst.Secret("ZEN_MODELS7"),
] ]
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) }, properties: { value: auth.url.apply((url) => url!) },
@@ -147,6 +162,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY, EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID, AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK,
...ZEN_MODELS, ...ZEN_MODELS,
...($dev ...($dev
? [ ? [
+145 -118
View File
@@ -16,16 +16,19 @@ Usage: install.sh [options]
Options: Options:
-h, --help Display this help message -h, --help Display this help message
-v, --version <version> Install a specific version (e.g., 1.0.180) -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.) --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
Examples: Examples:
curl -fsSL https://opencode.ai/install | bash curl -fsSL https://opencode.ai/install | bash
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180 curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
./install --binary /path/to/opencode
EOF EOF
} }
requested_version=${VERSION:-} requested_version=${VERSION:-}
no_modify_path=false no_modify_path=false
binary_path=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do
exit 1 exit 1
fi 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)
no_modify_path=true no_modify_path=true
shift shift
@@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do
esac esac
done 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 INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then # If --binary is provided, skip all download/detection logic
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename" if [ -n "$binary_path" ]; then
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') if [ ! -f "$binary_path" ]; then
echo -e "${RED}Error: Binary not found at ${binary_path}${NC}"
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
exit 1 exit 1
fi fi
specific_version="local"
else else
# Strip leading 'v' if present raw_os=$(uname -s)
requested_version="${requested_version#v}" os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename" case "$raw_os" in
specific_version=$requested_version Darwin*) os="darwin" ;;
Linux*) os="linux" ;;
# Verify the release exists before downloading MINGW*|MSYS*|CYGWIN*) os="windows" ;;
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}") esac
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}" arch=$(uname -m)
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}" 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 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
fi fi
@@ -187,11 +208,8 @@ check_version() {
if command -v opencode >/dev/null 2>&1; then if command -v opencode >/dev/null 2>&1; then
opencode_path=$(which opencode) opencode_path=$(which opencode)
## Check the installed version
## TODO: check if version is installed installed_version=$(opencode --version 2>/dev/null || echo "")
# installed_version=$(opencode version)
installed_version="0.0.1"
installed_version=$(echo $installed_version | awk '{print $2}')
if [[ "$installed_version" != "$specific_version" ]]; then if [[ "$installed_version" != "$specific_version" ]]; then
print_message info "${MUTED}Installed version: ${NC}$installed_version." print_message info "${MUTED}Installed version: ${NC}$installed_version."
@@ -267,11 +285,11 @@ download_with_progress() {
{ {
local length=0 local length=0
local bytes=0 local bytes=0
while IFS=" " read -r -a line; do while IFS=" " read -r -a line; do
[ "${#line[@]}" -lt 2 ] && continue [ "${#line[@]}" -lt 2 ] && continue
local tag="${line[0]} ${line[1]}" local tag="${line[0]} ${line[1]}"
if [ "$tag" = "0000: content-length:" ]; then if [ "$tag" = "0000: content-length:" ]; then
length="${line[2]}" length="${line[2]}"
length=$(echo "$length" | tr -d '\r') 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" print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$" local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
mkdir -p "$tmp_dir" mkdir -p "$tmp_dir"
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then 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 # Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
curl -# -L -o "$tmp_dir/$filename" "$url" curl -# -L -o "$tmp_dir/$filename" "$url"
@@ -307,14 +325,24 @@ download_and_install() {
else else
unzip -q "$tmp_dir/$filename" -d "$tmp_dir" unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
fi fi
mv "$tmp_dir/opencode" "$INSTALL_DIR" mv "$tmp_dir/opencode" "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode" chmod 755 "${INSTALL_DIR}/opencode"
rm -rf "$tmp_dir" rm -rf "$tmp_dir"
} }
check_version install_from_binary() {
download_and_install 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() { add_to_path() {
@@ -416,4 +444,3 @@ echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs" echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
echo -e "" echo -e ""
echo -e "" echo -e ""
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"nodeModules": "sha256-OJ3C4RMzfbbG1Fwa/5yru0rlISj+28UPITMNBEU5AeM=" "nodeModules": "sha256-KjBAaI9Kv6huOmPvUbtyYsMhbScI91w1lOZyXpIWqI0="
} }
+6 -1
View File
@@ -60,7 +60,12 @@ const result = await Bun.build({
compile: { compile: {
target, target,
outfile: "opencode", 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: {}, windows: {},
}, },
}) })
+2 -2
View File
@@ -1,8 +1,8 @@
## Usage ## 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 ```bash
$ npm install # or pnpm install or yarn install $ npm install # or pnpm install or yarn install
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.2", "version": "1.1.8",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+47
View File
@@ -242,6 +242,53 @@ describe("SerializeAddon", () => {
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42") 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 () => { test("serialized output written to new terminal should match original colors", async () => {
const { term, addon } = createTerminal(40, 5) const { term, addon } = createTerminal(40, 5)
+19 -23
View File
@@ -157,23 +157,6 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
abstract class BaseSerializeHandler { abstract class BaseSerializeHandler {
constructor(protected readonly _buffer: IBuffer) {} 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 { public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
let oldCell = this._buffer.getNullCell() let oldCell = this._buffer.getNullCell()
@@ -182,14 +165,14 @@ abstract class BaseSerializeHandler {
const startColumn = range.start.x const startColumn = range.start.x
const endColumn = range.end.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++) { for (let row = startRow; row <= endRow; row++) {
const line = this._buffer.getLine(row) const line = this._buffer.getLine(row)
if (line) { if (line) {
const startLineColumn = row === range.start.y ? startColumn : 0 const startLineColumn = row === range.start.y ? startColumn : 0
const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line) const endLineColumn = Math.min(endColumn, line.length)
const endLineColumn = Math.min(maxColumn, line.length)
for (let col = startLineColumn; col < endLineColumn; col++) { for (let col = startLineColumn; col < endLineColumn; col++) {
const c = line.getCell(col) const c = line.getCell(col)
if (!c) { if (!c) {
@@ -243,6 +226,13 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _beforeSerialize(rows: number, start: number, _end: number): void { protected _beforeSerialize(rows: number, start: number, _end: number): void {
this._allRows = new Array<string>(rows) 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._lastContentCursorRow = start
this._lastCursorRow = start this._lastCursorRow = start
this._firstRow = start this._firstRow = start
@@ -251,6 +241,11 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _rowEnd(row: number, isLastRow: boolean): void { protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = "" let rowSeparator = ""
if (this._nullCellCount > 0) {
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
if (!isLastRow) { if (!isLastRow) {
const nextLine = this._buffer.getLine(row + 1) const nextLine = this._buffer.getLine(row + 1)
@@ -388,7 +383,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
} }
const codepoint = cell.getCode() 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 isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
const sgrSeq = this._diffStyle(cell, this._cursorStyle) const sgrSeq = this._diffStyle(cell, this._cursorStyle)
@@ -397,7 +393,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (styleChanged) { if (styleChanged) {
if (this._nullCellCount > 0) { if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C` this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0 this._nullCellCount = 0
} }
@@ -417,7 +413,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._nullCellCount += cell.getWidth() this._nullCellCount += cell.getWidth()
} else { } else {
if (this._nullCellCount > 0) { if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C` this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0 this._nullCellCount = 0
} }
+75 -60
View File
@@ -1,5 +1,5 @@
import "@/index.css" 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 { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
@@ -20,16 +20,20 @@ import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { Logo } from "@opencode-ai/ui/logo"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import Home from "@/pages/home"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { iife } from "@opencode-ai/util/iife" 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 { declare global {
interface Window { interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number } __OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
} }
} }
@@ -45,6 +49,25 @@ const defaultServerUrl = iife(() => {
return window.location.origin 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) { function ServerKey(props: ParentProps) {
const server = useServer() const server = useServer()
return ( return (
@@ -54,62 +77,54 @@ function ServerKey(props: ParentProps) {
) )
} }
export function App() { export function AppInterface() {
return ( return (
<MetaProvider> <ServerProvider defaultUrl={defaultServerUrl}>
<Font /> <ServerKey>
<ThemeProvider> <GlobalSDKProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <GlobalSyncProvider>
<DialogProvider> <Router
<MarkedProvider> root={(props) => (
<DiffComponentProvider component={Diff}> <PermissionProvider>
<CodeComponentProvider component={Code}> <LayoutProvider>
<ServerProvider defaultUrl={defaultServerUrl}> <NotificationProvider>
<ServerKey> <CommandProvider>
<GlobalSDKProvider> <Layout>{props.children}</Layout>
<GlobalSyncProvider> </CommandProvider>
<Router </NotificationProvider>
root={(props) => ( </LayoutProvider>
<PermissionProvider> </PermissionProvider>
<LayoutProvider> )}
<NotificationProvider> >
<CommandProvider> <Route
<Layout>{props.children}</Layout> path="/"
</CommandProvider> component={() => (
</NotificationProvider> <Suspense fallback={<Loading />}>
</LayoutProvider> <Home />
</PermissionProvider> </Suspense>
)} )}
> />
<Route path="/" component={Home} /> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/:dir" component={DirectoryLayout}> <Route path="/" component={() => <Navigate href="session" />} />
<Route path="/" component={() => <Navigate href="session" />} /> <Route
<Route path="/session/:id?"
path="/session/:id?" component={() => (
component={(p) => ( <TerminalProvider>
<Show when={p.params.id ?? "new"} keyed> <FileProvider>
<TerminalProvider> <PromptProvider>
<FileProvider> <Suspense fallback={<Loading />}>
<PromptProvider> <Session />
<Session /> </Suspense>
</PromptProvider> </PromptProvider>
</FileProvider> </FileProvider>
</TerminalProvider> </TerminalProvider>
</Show> )}
)} />
/> </Route>
</Route> </Router>
</Router> </GlobalSyncProvider>
</GlobalSyncProvider> </GlobalSDKProvider>
</GlobalSDKProvider> </ServerKey>
</ServerKey> </ServerProvider>
</ServerProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
) )
} }
@@ -7,15 +7,11 @@ import { createMemo, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { type LocalProject, getAvatarColors } from "@/context/layout" import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/util/path"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
function getFilename(input: string) {
const parts = input.split("/")
return parts[parts.length - 1] || input
}
export function DialogEditProject(props: { project: LocalProject }) { export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog() const dialog = useDialog()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
@@ -27,6 +27,7 @@ export function DialogSelectFile() {
const value = file.tab(path) const value = file.tab(path)
tabs().open(value) tabs().open(value)
file.load(path) file.load(path)
layout.review.open()
} }
dialog.close() dialog.close()
}} }}
+68 -42
View File
@@ -1,5 +1,17 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" import {
createEffect,
on,
Component,
Show,
For,
onMount,
onCleanup,
Switch,
Match,
createMemo,
createSignal,
} from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element" import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -30,7 +42,7 @@ import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
@@ -153,6 +165,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
) )
const working = createMemo(() => status()?.type !== "idle") const working = createMemo(() => status()?.type !== "idle")
const imageAttachments = createMemo(
() => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
)
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
popover: "at" | "slash" | null popover: "at" | "slash" | null
@@ -160,7 +175,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
savedPrompt: Prompt | null savedPrompt: Prompt | null
placeholder: number placeholder: number
dragging: boolean dragging: boolean
imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell" mode: "normal" | "shell"
applyingHistory: boolean applyingHistory: boolean
}>({ }>({
@@ -169,14 +183,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
savedPrompt: null, savedPrompt: null,
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
dragging: false, dragging: false,
imageAttachments: [],
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
}) })
const MAX_HISTORY = 100 const MAX_HISTORY = 100
const [history, setHistory] = persisted( const [history, setHistory] = persisted(
"prompt-history.v1", Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{ createStore<{
entries: Prompt[] entries: Prompt[]
}>({ }>({
@@ -184,7 +197,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}), }),
) )
const [shellHistory, setShellHistory] = persisted( const [shellHistory, setShellHistory] = persisted(
"prompt-history-shell.v1", Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{ createStore<{
entries: Prompt[] entries: Prompt[]
}>({ }>({
@@ -235,6 +248,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const isFocused = createFocusSignal(() => editorRef)
createEffect(() => { createEffect(() => {
params.id params.id
editorRef.focus() editorRef.focus()
@@ -245,7 +260,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onCleanup(() => clearInterval(interval)) onCleanup(() => clearInterval(interval))
}) })
const isFocused = createFocusSignal(() => editorRef) const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const addImageAttachment = async (file: File) => { const addImageAttachment = async (file: File) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
@@ -260,21 +276,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mime: file.type, mime: file.type,
dataUrl, dataUrl,
} }
setStore( const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
produce((draft) => { prompt.set([...prompt.current(), attachment], cursorPosition)
draft.imageAttachments.push(attachment)
}),
)
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
const removeImageAttachment = (id: string) => { const removeImageAttachment = (id: string) => {
setStore( const current = prompt.current()
produce((draft) => { const next = current.filter((part) => part.type !== "image" || part.id !== id)
draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) prompt.set(next, prompt.cursor())
}),
)
} }
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
@@ -282,12 +293,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const clipboardData = event.clipboardData const clipboardData = event.clipboardData
if (!clipboardData) return if (!clipboardData) return
event.preventDefault()
event.stopPropagation()
const items = Array.from(clipboardData.items) const items = Array.from(clipboardData.items)
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) { if (imageItems.length > 0) {
event.preventDefault()
event.stopPropagation()
for (const item of imageItems) { for (const item of imageItems) {
const file = item.getAsFile() const file = item.getAsFile()
if (file) await addImageAttachment(file) if (file) await addImageAttachment(file)
@@ -295,13 +307,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
event.preventDefault()
event.stopPropagation()
const plainText = clipboardData.getData("text/plain") ?? "" const plainText = clipboardData.getData("text/plain") ?? ""
addPart({ type: "text", content: plainText, start: 0, end: 0 }) addPart({ type: "text", content: plainText, start: 0, end: 0 })
} }
const handleGlobalDragOver = (event: DragEvent) => { const handleGlobalDragOver = (event: DragEvent) => {
if (dialog.active) return
event.preventDefault() event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files") const hasFiles = event.dataTransfer?.types.includes("Files")
if (hasFiles) { if (hasFiles) {
@@ -310,6 +322,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const handleGlobalDragLeave = (event: DragEvent) => { const handleGlobalDragLeave = (event: DragEvent) => {
if (dialog.active) return
// relatedTarget is null when leaving the document window // relatedTarget is null when leaving the document window
if (!event.relatedTarget) { if (!event.relatedTarget) {
setStore("dragging", false) setStore("dragging", false)
@@ -317,6 +331,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const handleGlobalDrop = async (event: DragEvent) => { const handleGlobalDrop = async (event: DragEvent) => {
if (dialog.active) return
event.preventDefault() event.preventDefault()
setStore("dragging", false) setStore("dragging", false)
@@ -331,13 +347,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
onMount(() => { onMount(() => {
editorRef.addEventListener("paste", handlePaste)
document.addEventListener("dragover", handleGlobalDragOver) document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave) document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop) document.addEventListener("drop", handleGlobalDrop)
}) })
onCleanup(() => { onCleanup(() => {
editorRef.removeEventListener("paste", handlePaste)
document.removeEventListener("dragover", handleGlobalDragOver) document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave) document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop) document.removeEventListener("drop", handleGlobalDrop)
@@ -518,8 +532,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
on( on(
() => prompt.current(), () => prompt.current(),
(currentParts) => { (currentParts) => {
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
const domParts = parseFromDOM() const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
const selection = window.getSelection() const selection = window.getSelection()
let cursorPosition: number | null = null let cursorPosition: number | null = null
@@ -527,7 +542,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
cursorPosition = getCursorPosition(editorRef) cursorPosition = getCursorPosition(editorRef)
} }
renderEditor(currentParts) renderEditor(inputParts)
if (cursorPosition !== null) { if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition) setCursorPosition(editorRef, cursorPosition)
@@ -618,11 +633,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleInput = () => { const handleInput = () => {
const rawParts = parseFromDOM() const rawParts = parseFromDOM()
const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef) const cursorPosition = getCursorPosition(editorRef)
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const trimmed = rawText.replace(/\u200B/g, "").trim() const trimmed = rawText.replace(/\u200B/g, "").trim()
const hasNonText = rawParts.some((part) => part.type !== "text") const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = trimmed.length === 0 && !hasNonText const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
if (shouldReset) { if (shouldReset) {
setStore("popover", null) setStore("popover", null)
@@ -661,7 +677,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null) setStore("savedPrompt", null)
} }
prompt.set(rawParts, cursorPosition) prompt.set([...rawParts, ...images], cursorPosition)
queueScroll() queueScroll()
} }
@@ -764,16 +780,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.map((p) => ("content" in p ? p.content : "")) .map((p) => ("content" in p ? p.content : ""))
.join("") .join("")
.trim() .trim()
if (!text) return const hasImages = prompt.some((part) => part.type === "image")
if (!text && !hasImages) return
const entry = clonePromptParts(prompt) const entry = clonePromptParts(prompt)
const currentHistory = mode === "shell" ? shellHistory : history const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const lastEntry = currentHistory.entries[0] const lastEntry = currentHistory.entries[0]
if (lastEntry) { if (lastEntry && isPromptEqual(lastEntry, entry)) return
const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
if (lastText === text) return
}
setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
} }
@@ -862,6 +876,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
if (event.key === "Enter" && isImeComposing(event)) {
return
}
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
if (store.popover === "at") { if (store.popover === "at") {
atOnKeyDown(event) atOnKeyDown(event)
@@ -943,7 +961,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const currentPrompt = prompt.current() const currentPrompt = prompt.current()
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = store.imageAttachments.slice() const images = imageAttachments().slice()
const mode = store.mode const mode = store.mode
if (text.trim().length === 0 && images.length === 0) { if (text.trim().length === 0 && images.length === 0) {
@@ -1037,14 +1055,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const clearInput = () => { const clearInput = () => {
prompt.reset() prompt.reset()
setStore("imageAttachments", [])
setStore("mode", "normal") setStore("mode", "normal")
setStore("popover", null) setStore("popover", null)
} }
const restoreInput = () => { const restoreInput = () => {
prompt.set(currentPrompt, promptLength(currentPrompt)) prompt.set(currentPrompt, promptLength(currentPrompt))
setStore("imageAttachments", images)
setStore("mode", mode) setStore("mode", mode)
setStore("popover", null) setStore("popover", null)
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -1087,6 +1103,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
agent, agent,
model: `${model.providerID}/${model.modelID}`, model: `${model.providerID}/${model.modelID}`,
variant, variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
}) })
.catch((err) => { .catch((err) => {
showToast({ showToast({
@@ -1440,9 +1463,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</For> </For>
</div> </div>
</Show> </Show>
<Show when={store.imageAttachments.length > 0}> <Show when={imageAttachments().length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3"> <div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={store.imageAttachments}> <For each={imageAttachments()}>
{(attachment) => ( {(attachment) => (
<div class="relative group"> <div class="relative group">
<Show <Show
@@ -1483,6 +1506,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
contenteditable="true" contenteditable="true"
onInput={handleInput} onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
"select-text": true, "select-text": true,
@@ -1492,7 +1518,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"font-mono!": store.mode === "shell", "font-mono!": store.mode === "shell",
}} }}
/> />
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}> <Show when={!prompt.dirty()}>
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"> <div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{store.mode === "shell" {store.mode === "shell"
? "Enter shell command..." ? "Enter shell command..."
@@ -1566,14 +1592,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{ classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!), "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}} }}
> >
<Icon <Icon
name="chevron-double-right" name="chevron-double-right"
size="small" size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }} classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/> />
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
@@ -1625,7 +1651,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<IconButton <IconButton
type="submit" type="submit"
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()} disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="h-6 w-4.5" class="h-6 w-4.5"
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createResource, Show } from "solid-js" import { createMemo, createResource, Show } from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
@@ -20,6 +20,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator" import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator" import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { same } from "@/utils/same"
export function SessionHeader() { export function SessionHeader() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
@@ -34,8 +35,14 @@ export function SessionHeader() {
const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
const parentSession = createMemo(() => {
const current = currentSession()
if (!current?.parentID) return undefined
return sync.data.session.find((s) => s.id === current.parentID)
})
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
function navigateToProject(directory: string) { function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`) navigate(`/${base64Encode(directory)}`)
@@ -43,6 +50,8 @@ export function SessionHeader() {
function navigateToSession(session: Session | undefined) { function navigateToSession(session: Session | undefined) {
if (!session) return 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}`) navigate(`/${params.dir}/session/${session.id}`)
} }
@@ -60,7 +69,7 @@ export function SessionHeader() {
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2"> <div class="hidden xl:flex items-center gap-2">
<Select <Select
options={layout.projects.list().map((project) => project.worktree)} options={worktrees()}
current={sync.project?.worktree ?? projectDirectory()} current={sync.project?.worktree ?? projectDirectory()}
label={(x) => getFilename(x)} label={(x) => getFilename(x)}
onSelect={(x) => (x ? navigateToProject(x) : undefined)} onSelect={(x) => (x ? navigateToProject(x) : undefined)}
@@ -77,18 +86,56 @@ export function SessionHeader() {
</Select> </Select>
<div class="text-text-weaker">/</div> <div class="text-text-weaker">/</div>
</div> </div>
<Select <Show
options={sessions()} when={parentSession()}
current={currentSession()} fallback={
placeholder="New session" <>
label={(x) => x.title} <Select
value={(x) => x.id} options={sessions()}
onSelect={navigateToSession} current={currentSession()}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" placeholder="New session"
variant="ghost" 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> </div>
<Show when={currentSession()}> <Show when={currentSession() && !parentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}> <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" /> <IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind> </TooltipKeybind>
@@ -197,8 +244,13 @@ export function SessionHeader() {
} }
return shareURL return shareURL
}, },
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
</Show>
) )
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
})} })}
</Popover> </Popover>
</Show> </Show>
@@ -23,11 +23,18 @@ export function NewSessionView(props: NewSessionViewProps) {
if (options().includes(selection)) return selection if (options().includes(selection)) return selection
return MAIN_WORKTREE 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) => { const label = (value: string) => {
if (value === MAIN_WORKTREE) { if (value === MAIN_WORKTREE) {
if (isWorktree()) return "Main branch"
const branch = sync.data.vcs?.branch const branch = sync.data.vcs?.branch
if (branch) return `Current branch (${branch})` if (branch) return `Main branch (${branch})`
return "Main branch" return "Main branch"
} }
@@ -37,13 +44,16 @@ export function NewSessionView(props: NewSessionViewProps) {
} }
return ( return (
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"> <div
class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"
style={{ "padding-bottom": "calc(var(--prompt-height, 11.25rem) + 64px)" }}
>
<div class="text-20-medium text-text-weaker">New session</div> <div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3"> <div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" /> <Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak"> <div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)} {getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span> <span class="text-text-strong">{getFilename(projectRoot())}</span>
</div> </div>
</div> </div>
<div class="flex justify-center items-center gap-1"> <div class="flex justify-center items-center gap-1">
@@ -39,6 +39,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
</Tooltip> </Tooltip>
} }
hideCloseButton hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
> >
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show> <Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</Tabs.Trigger> </Tabs.Trigger>
+105 -71
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 { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize" import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal" 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"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
@@ -16,6 +16,7 @@ type TerminalColors = {
background: string background: string
foreground: string foreground: string
cursor: string cursor: string
selectionBackground: string
} }
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
@@ -23,11 +24,13 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
background: "#fcfcfc", background: "#fcfcfc",
foreground: "#211e1e", foreground: "#211e1e",
cursor: "#211e1e", cursor: "#211e1e",
selectionBackground: withAlpha("#211e1e", 0.2),
}, },
dark: { dark: {
background: "#191515", background: "#191515",
foreground: "#d4d4d4", foreground: "#d4d4d4",
cursor: "#d4d4d4", cursor: "#d4d4d4",
selectionBackground: withAlpha("#d4d4d4", 0.25),
}, },
} }
@@ -36,12 +39,14 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme() const theme = useTheme()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket let ws: WebSocket | undefined
let term: Term let term: Term | undefined
let ghostty: Ghostty let ghostty: Ghostty
let serializeAddon: SerializeAddon let serializeAddon: SerializeAddon
let fitAddon: FitAddon let fitAddon: FitAddon
let handleResize: () => void let handleResize: () => void
let reconnect: number | undefined
let disposed = false
const getTerminalColors = (): TerminalColors => { const getTerminalColors = (): TerminalColors => {
const mode = theme.mode() const mode = theme.mode()
@@ -51,12 +56,16 @@ export const Terminal = (props: TerminalProps) => {
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds) return fallback if (!variant?.seeds) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark") 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 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 { return {
background, background,
foreground: text, foreground: text,
cursor: text, cursor: text,
selectionBackground,
} }
} }
@@ -71,27 +80,11 @@ export const Terminal = (props: TerminalProps) => {
setOption("theme", colors) setOption("theme", colors)
}) })
const focusTerminal = () => term?.focus() const focusTerminal = () => {
const copySelection = () => { const t = term
if (!term || !term.hasSelection()) return false if (!t) return
const selection = term.getSelection() t.focus()
if (!selection) return false setTimeout(() => t.textarea?.focus(), 0)
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 handlePointerDown = () => { const handlePointerDown = () => {
const activeElement = document.activeElement const activeElement = document.activeElement
@@ -102,10 +95,15 @@ export const Terminal = (props: TerminalProps) => {
} }
onMount(async () => { 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)}`) const socket = new WebSocket(
term = new Term({ sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
)
ws = socket
const t = new mod.Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 14, fontSize: 14,
fontFamily: "IBM Plex Mono, monospace", fontFamily: "IBM Plex Mono, monospace",
@@ -114,50 +112,83 @@ export const Terminal = (props: TerminalProps) => {
scrollback: 10_000, scrollback: 10_000,
ghostty, ghostty,
}) })
term.attachCustomKeyEventHandler((event) => { term = t
const key = event.key.toLowerCase()
if (key === "c") { const copy = () => {
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey const selection = t.getSelection()
const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey if (!selection) return false
if ((macCopy || linuxCopy) && copySelection()) {
event.preventDefault() const body = document.body
return true 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 === "`") { const clipboard = navigator.clipboard
event.preventDefault() if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true 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 return false
}) })
fitAddon = new FitAddon() fitAddon = new mod.FitAddon()
serializeAddon = new SerializeAddon() serializeAddon = new SerializeAddon()
term.loadAddon(serializeAddon) t.loadAddon(serializeAddon)
term.loadAddon(fitAddon) t.loadAddon(fitAddon)
term.open(container) t.open(container)
container.addEventListener("pointerdown", handlePointerDown) container.addEventListener("pointerdown", handlePointerDown)
focusTerminal() focusTerminal()
if (local.pty.buffer) { if (local.pty.buffer) {
if (local.pty.rows && local.pty.cols) { 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() t.write(local.pty.buffer, () => {
term.write(local.pty.buffer) if (local.pty.scrollY) {
if (local.pty.scrollY) { t.scrollToLine(local.pty.scrollY)
term.scrollToLine(local.pty.scrollY) }
} fitAddon.fit()
fitAddon.fit() })
} }
fitAddon.observeResize() fitAddon.observeResize()
handleResize = () => fitAddon.fit() handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
term.onResize(async (size) => { t.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty await sdk.client.pty
.update({ .update({
ptyID: local.pty.id, ptyID: local.pty.id,
@@ -169,39 +200,39 @@ export const Terminal = (props: TerminalProps) => {
.catch(() => {}) .catch(() => {})
} }
}) })
term.onData((data) => { t.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
ws.send(data) socket.send(data)
} }
}) })
term.onKey((key) => { t.onKey((key) => {
if (key.key == "Enter") { if (key.key == "Enter") {
props.onSubmit?.() props.onSubmit?.()
} }
}) })
// term.onScroll((ydisp) => { // t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp) // console.log("Scroll position:", ydisp)
// }) // })
ws.addEventListener("open", () => { socket.addEventListener("open", () => {
console.log("WebSocket connected") console.log("WebSocket connected")
sdk.client.pty sdk.client.pty
.update({ .update({
ptyID: local.pty.id, ptyID: local.pty.id,
size: { size: {
cols: term.cols, cols: t.cols,
rows: term.rows, rows: t.rows,
}, },
}) })
.catch(() => {}) .catch(() => {})
}) })
ws.addEventListener("message", (event) => { socket.addEventListener("message", (event) => {
term.write(event.data) t.write(event.data)
}) })
ws.addEventListener("error", (error) => { socket.addEventListener("error", (error) => {
console.error("WebSocket error:", error) console.error("WebSocket error:", error)
props.onConnectError?.(error) props.onConnectError?.(error)
}) })
ws.addEventListener("close", () => { socket.addEventListener("close", () => {
console.log("WebSocket disconnected") console.log("WebSocket disconnected")
}) })
}) })
@@ -211,18 +242,21 @@ export const Terminal = (props: TerminalProps) => {
window.removeEventListener("resize", handleResize) window.removeEventListener("resize", handleResize)
} }
container.removeEventListener("pointerdown", handlePointerDown) container.removeEventListener("pointerdown", handlePointerDown)
if (serializeAddon && props.onCleanup) {
const t = term
if (serializeAddon && props.onCleanup && t) {
const buffer = serializeAddon.serialize() const buffer = serializeAddon.serialize()
props.onCleanup({ props.onCleanup({
...local.pty, ...local.pty,
buffer, buffer,
rows: term.rows, rows: t.rows,
cols: term.cols, cols: t.cols,
scrollY: term.getViewportY(), scrollY: t.getViewportY(),
}) })
} }
ws?.close() ws?.close()
term?.dispose() t?.dispose()
}) })
return ( return (
+12 -1
View File
@@ -177,8 +177,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const dialog = useDialog() const dialog = useDialog()
const options = createMemo(() => { 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) const suggested = all.filter((x) => x.suggested && !x.disabled)
return [ return [
...suggested.map((x) => ({ ...suggested.map((x) => ({
...x, ...x,
+149 -38
View File
@@ -1,4 +1,4 @@
import { createMemo, onCleanup } from "solid-js" import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2" import type { FileContent } from "@opencode-ai/sdk/v2"
@@ -7,7 +7,7 @@ import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
export type FileSelection = { export type FileSelection = {
startLine: number startLine: number
@@ -82,8 +82,106 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
} }
} }
const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
type ViewSession = ReturnType<typeof createViewSession>
type ViewCacheEntry = {
value: ViewSession
dispose: VoidFunction
}
function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted(
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
const meta = { 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 (meta.pruned) return
meta.pruned = true
pruneView()
})
const scrollTop = (path: string) => view.file[path]?.scrollTop
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
const selectedLines = (path: string) => view.file[path]?.selectedLines
const setScrollTop = (path: string, top: number) => {
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
pruneView(path)
}
const setScrollLeft = (path: string, left: number) => {
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
pruneView(path)
}
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
pruneView(path)
}
return {
ready,
scrollTop,
scrollLeft,
selectedLines,
setScrollTop,
setScrollLeft,
setSelectedLines,
}
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({ export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File", name: "File",
gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -134,16 +232,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {}, file: {},
}) })
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`) const viewCache = new Map<string, ViewCacheEntry>()
const [view, setView, _, ready] = persisted( const disposeViews = () => {
viewKey(), for (const entry of viewCache.values()) {
createStore<{ entry.dispose()
file: Record<string, FileViewState> }
}>({ viewCache.clear()
file: {}, }
}),
) const pruneViews = () => {
while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
const first = viewCache.keys().next().value
if (!first) return
const entry = viewCache.get(first)
entry?.dispose()
viewCache.delete(first)
}
}
const loadView = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = viewCache.get(key)
if (existing) {
viewCache.delete(key)
viewCache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createViewSession(dir, id),
dispose,
}))
viewCache.set(key, entry)
pruneViews()
return entry.value
}
const view = createMemo(() => loadView(params.dir!, params.id))
function ensure(path: string) { function ensure(path: string) {
if (!path) return if (!path) return
@@ -220,48 +347,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const get = (input: string) => store.file[normalize(input)] const get = (input: string) => store.file[normalize(input)]
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop const scrollTop = (input: string) => view().scrollTop(normalize(input))
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines const selectedLines = (input: string) => view().selectedLines(normalize(input))
const setScrollTop = (input: string, top: number) => { const setScrollTop = (input: string, top: number) => {
const path = normalize(input) const path = normalize(input)
setView("file", path, (current) => { view().setScrollTop(path, top)
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
} }
const setScrollLeft = (input: string, left: number) => { const setScrollLeft = (input: string, left: number) => {
const path = normalize(input) const path = normalize(input)
setView("file", path, (current) => { view().setScrollLeft(path, left)
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
} }
const setSelectedLines = (input: string, range: SelectedLineRange | null) => { const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
const path = normalize(input) const path = normalize(input)
const next = range ? normalizeSelectedLines(range) : null view().setSelectedLines(path, range)
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
} }
onCleanup(() => stop()) onCleanup(() => {
stop()
disposeViews()
})
return { return {
ready, ready: () => view().ready(),
normalize, normalize,
tab, tab,
pathFromTab, pathFromTab,
+70 -5
View File
@@ -1,7 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js" import { batch, onCleanup } from "solid-js"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { useServer } from "./server" import { useServer } from "./server"
@@ -19,14 +19,79 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
[key: string]: Event [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 () => { void (async () => {
const events = await eventSdk.global.event() const events = await eventSdk.global.event()
let yielded = Date.now()
for await (const event of events.stream) { for await (const event of events.stream) {
emitter.emit(event.directory ?? "global", event.payload) const directory = event.directory ?? "global"
} const payload = event.payload
})().catch(() => undefined) 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 platform = usePlatform()
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
+3 -2
View File
@@ -23,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error" 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 { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
@@ -212,7 +212,7 @@ function createGlobalSync() {
.catch((e) => setGlobalStore("error", e)) .catch((e) => setGlobalStore("error", e))
} }
globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const event = e.details const event = e.details
@@ -404,6 +404,7 @@ function createGlobalSync() {
} }
} }
}) })
onCleanup(unsub)
async function bootstrap() { async function bootstrap() {
const health = await globalSDK.client.global const health = await globalSDK.client.global
@@ -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()
})
})
})
})
})
+118
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,
}
}
+165 -31
View File
@@ -1,11 +1,13 @@
import { createStore, produce } from "solid-js/store" 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 { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server" import { useServer } from "./server"
import { Project } from "@opencode-ai/sdk/v2" 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 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -23,23 +25,11 @@ export function getAvatarColors(key?: string) {
} }
} }
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type SessionTabs = { type SessionTabs = {
active?: string active?: string
all: string[] all: string[]
} }
type SessionScroll = {
x: number
y: number
}
type SessionView = { type SessionView = {
scroll: Record<string, SessionScroll> scroll: Record<string, SessionScroll>
reviewOpen?: string[] reviewOpen?: string[]
@@ -56,7 +46,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const server = useServer() const server = useServer()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"layout.v6", Persist.global("layout", ["layout.v6"]),
createStore({ createStore({
sidebar: { sidebar: {
opened: false, opened: false,
@@ -81,6 +71,121 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}), }),
) )
const MAX_SESSION_KEYS = 50
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
const SESSION_STATE_KEYS = [
{ key: "prompt", legacy: "prompt", version: "v2" },
{ key: "terminal", legacy: "terminal", version: "v1" },
{ key: "file-view", legacy: "file", version: "v1" },
] as const
const dropSessionState = (keys: string[]) => {
for (const key of keys) {
const parts = key.split("/")
const dir = parts[0]
const session = parts[1]
if (!dir) continue
for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
void removePersisted(target)
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey })
}
}
}
function prune(keep?: string) {
if (!keep) return
const keys = new Set<string>()
for (const key of Object.keys(store.sessionView)) keys.add(key)
for (const key of Object.keys(store.sessionTabs)) keys.add(key)
if (keys.size <= MAX_SESSION_KEYS) return
const score = (key: string) => {
if (key === keep) return Number.MAX_SAFE_INTEGER
return used.get(key) ?? 0
}
const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
const drop = ordered.slice(MAX_SESSION_KEYS)
if (drop.length === 0) return
setStore(
produce((draft) => {
for (const key of drop) {
delete draft.sessionView[key]
delete draft.sessionTabs[key]
}
}),
)
scroll.drop(drop)
dropSessionState(drop)
for (const key of drop) {
used.delete(key)
}
}
function touch(sessionKey: string) {
meta.active = sessionKey
used.set(sessionKey, Date.now())
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
prune(sessionKey)
}
const scroll = createScrollPersistence({
debounceMs: 250,
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
onFlush: (sessionKey, next) => {
const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
prune(keep)
return
}
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
prune(keep)
},
})
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
const active = meta.active
if (!active) return
meta.pruned = true
prune(active)
})
onMount(() => {
const flush = () => batch(() => scroll.flushAll())
const handleVisibility = () => {
if (document.visibilityState !== "hidden") return
flush()
}
window.addEventListener("pagehide", flush)
document.addEventListener("visibilitychange", handleVisibility)
onCleanup(() => {
window.removeEventListener("pagehide", flush)
document.removeEventListener("visibilitychange", handleVisibility)
scroll.dispose()
})
})
const usedColors = new Set<AvatarColorKey>() const usedColors = new Set<AvatarColorKey>()
function pickAvailableColor(): AvatarColorKey { function pickAvailableColor(): AvatarColorKey {
@@ -97,8 +202,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
: globalSync.data.project.find((x) => x.worktree === project.worktree) : globalSync.data.project.find((x) => x.worktree === project.worktree)
return [ return [
{ {
...project,
...(metadata ?? {}), ...(metadata ?? {}),
...project,
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color }, icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
}, },
] ]
@@ -115,6 +220,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return project 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 enriched = createMemo(() => server.projects.list().flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize)) const list = createMemo(() => enriched().flatMap(colorize))
@@ -131,11 +271,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: { projects: {
list, list,
open(directory: string) { open(directory: string) {
if (server.projects.list().find((x) => x.worktree === directory)) { const root = roots().get(directory) ?? directory
return if (server.projects.list().find((x) => x.worktree === root)) return
} globalSync.project.loadSessions(root)
globalSync.project.loadSessions(directory) server.projects.open(root)
server.projects.open(directory)
}, },
close(directory: string) { close(directory: string) {
server.projects.close(directory) server.projects.close(directory)
@@ -225,21 +364,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
}, },
view(sessionKey: string) { view(sessionKey: string) {
touch(sessionKey)
scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} }) const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
return { return {
scroll(tab: string) { scroll(tab: string) {
return s().scroll?.[tab] return scroll.scroll(sessionKey, tab)
}, },
setScroll(tab: string, pos: SessionScroll) { setScroll(tab: string, pos: SessionScroll) {
const current = store.sessionView[sessionKey] scroll.setScroll(sessionKey, tab, pos)
if (!current) {
setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
return
}
const prev = current.scroll?.[tab]
if (prev?.x === pos.x && prev?.y === pos.y) return
setStore("sessionView", sessionKey, "scroll", tab, pos)
}, },
review: { review: {
open: createMemo(() => s().reviewOpen), open: createMemo(() => s().reviewOpen),
@@ -257,6 +390,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
} }
}, },
tabs(sessionKey: string) { tabs(sessionKey: string) {
touch(sessionKey)
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return { return {
tabs, tabs,
+12 -5
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store" 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 { 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 type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -8,7 +8,7 @@ import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
export type LocalFile = FileNode & export type LocalFile = FileNode &
@@ -111,7 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const model = (() => { const model = (() => {
const [store, setStore, _, modelReady] = persisted( const [store, setStore, _, modelReady] = persisted(
"model.v1", Persist.global("model", ["model.v1"]),
createStore<{ createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[] user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[] recent: ModelKey[]
@@ -276,7 +276,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
visible(model: ModelKey) { visible(model: ModelKey) {
const key = `${model.providerID}:${model.modelID}` const key = `${model.providerID}:${model.modelID}`
const visibility = userVisibilityMap().get(key) const visibility = userVisibilityMap().get(key)
return visibility !== "hide" && (latestSet().has(key) || visibility === "show") if (visibility === "hide") return false
if (visibility === "show") return true
if (latestSet().has(key)) return true
// For models without valid release_date (e.g. custom models), show by default
const m = find(model)
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
return false
}, },
setVisibility(model: ModelKey, visible: boolean) { setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide") updateVisibility(model, visible ? "show" : "hide")
@@ -465,7 +471,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const searchFilesAndDirectories = (query: string) => const searchFilesAndDirectories = (query: string) =>
sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!) 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 const event = e.details
switch (event.type) { switch (event.type) {
case "file.watcher.updated": case "file.watcher.updated":
@@ -475,6 +481,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
break break
} }
}) })
onCleanup(unsub)
return { return {
node: async (path: string) => { node: async (path: string) => {
+30 -5
View File
@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createEffect, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
@@ -9,7 +10,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio" import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
import errorSound from "@opencode-ai/ui/audio/nope-03.aac" import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
@@ -30,6 +31,16 @@ type ErrorNotification = NotificationBase & {
export type Notification = TurnCompleteNotification | ErrorNotification 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({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
init: () => { init: () => {
@@ -48,13 +59,26 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const platform = usePlatform() const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"notification.v1", Persist.global("notification", ["notification.v1"]),
createStore({ createStore({
list: [] as Notification[], 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 directory = e.name
const event = e.details const event = e.details
const base = { const base = {
@@ -72,7 +96,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
try { try {
idlePlayer?.play() idlePlayer?.play()
} catch {} } catch {}
setStore("list", store.list.length, { append({
...base, ...base,
type: "turn-complete", type: "turn-complete",
session: sessionID, session: sessionID,
@@ -91,7 +115,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
errorPlayer?.play() errorPlayer?.play()
} catch {} } catch {}
const error = "error" in event.properties ? event.properties.error : undefined const error = "error" in event.properties ? event.properties.error : undefined
setStore("list", store.list.length, { append({
...base, ...base,
type: "error", type: "error",
session: sessionID ?? "global", session: sessionID ?? "global",
@@ -104,6 +128,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
} }
} }
}) })
onCleanup(unsub)
return { return {
ready, ready,
+65 -20
View File
@@ -1,12 +1,12 @@
import { createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
type PermissionRespondFn = (input: { type PermissionRespondFn = (input: {
sessionID: string sessionID: string
@@ -19,6 +19,32 @@ function shouldAutoAccept(perm: PermissionRequest) {
return perm.permission === "edit" return perm.permission === "edit"
} }
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
if (typeof rule !== "object") return false
if (Array.isArray(rule)) return false
for (const action of Object.values(rule)) {
if (action !== "allow") return true
}
return false
}
function hasAutoAcceptPermissionConfig(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
if (Array.isArray(permission)) return false
const config = permission as Record<string, unknown>
if (isNonAllowRule(config.edit)) return true
if (isNonAllowRule(config.write)) return true
return false
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
init: () => { init: () => {
@@ -27,13 +53,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => { const permissionsEnabled = createMemo(() => {
if (!params.dir || !base64Decode(params.dir)) return false const directory = params.dir ? base64Decode(params.dir) : undefined
const [store] = globalSync.child(base64Decode(params.dir)) if (!directory) return false
return store.config.permission !== undefined const [store] = globalSync.child(directory)
return hasAutoAcceptPermissionConfig(store.config.permission)
}) })
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"permission.v3", Persist.global("permission", ["permission.v3"]),
createStore({ createStore({
autoAcceptEdits: {} as Record<string, boolean>, autoAcceptEdits: {} as Record<string, boolean>,
}), }),
@@ -58,8 +85,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}) })
} }
function isAutoAccepting(sessionID: string) { function acceptKey(sessionID: string, directory?: string) {
return store.autoAcceptEdits[sessionID] ?? false if (!directory) return sessionID
return `${base64Encode(directory)}/${sessionID}`
}
function isAutoAccepting(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
} }
const unsubscribe = globalSDK.event.listen((e) => { const unsubscribe = globalSDK.event.listen((e) => {
@@ -67,7 +100,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (event?.type !== "permission.asked") return if (event?.type !== "permission.asked") return
const perm = event.properties const perm = event.properties
if (!isAutoAccepting(perm.sessionID)) return if (!isAutoAccepting(perm.sessionID, e.name)) return
if (!shouldAutoAccept(perm)) return if (!shouldAutoAccept(perm)) return
respondOnce(perm, e.name) respondOnce(perm, e.name)
@@ -75,7 +108,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
onCleanup(unsubscribe) onCleanup(unsubscribe)
function enable(sessionID: string, directory: string) { function enable(sessionID: string, directory: string) {
setStore("autoAcceptEdits", sessionID, true) const key = acceptKey(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAcceptEdits[key] = true
delete draft.autoAcceptEdits[sessionID]
}),
)
globalSDK.client.permission globalSDK.client.permission
.list({ directory }) .list({ directory })
@@ -90,31 +129,37 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
.catch(() => undefined) .catch(() => undefined)
} }
function disable(sessionID: string) { function disable(sessionID: string, directory?: string) {
setStore("autoAcceptEdits", sessionID, false) const key = directory ? acceptKey(sessionID, directory) : undefined
setStore(
produce((draft) => {
if (key) delete draft.autoAcceptEdits[key]
delete draft.autoAcceptEdits[sessionID]
}),
)
} }
return { return {
ready, ready,
respond, respond,
autoResponds(permission: PermissionRequest) { autoResponds(permission: PermissionRequest, directory?: string) {
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission) return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
}, },
isAutoAccepting, isAutoAccepting,
toggleAutoAccept(sessionID: string, directory: string) { toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID)) { if (isAutoAccepting(sessionID, directory)) {
disable(sessionID) disable(sessionID, directory)
return return
} }
enable(sessionID, directory) enable(sessionID, directory)
}, },
enableAutoAccept(sessionID: string, directory: string) { enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID)) return if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory) enable(sessionID, directory)
}, },
disableAutoAccept(sessionID: string) { disableAutoAccept(sessionID: string, directory?: string) {
disable(sessionID) disable(sessionID, directory)
}, },
permissionsEnabled, permissionsEnabled,
} }
+132 -60
View File
@@ -1,9 +1,9 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js" import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
interface PartBase { interface PartBase {
content: string content: string
@@ -99,74 +99,146 @@ function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart) return prompt.map(clonePart)
} }
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type PromptCacheEntry = {
value: PromptSession
dispose: VoidFunction
}
function createPromptSession(dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, 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(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt", name: "Prompt",
gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`) const cache = new Map<string, PromptCacheEntry>()
const [store, setStore, _, ready] = persisted( const disposeAll = () => {
name(), for (const entry of cache.values()) {
createStore<{ entry.dispose()
prompt: Prompt }
cursor?: number cache.clear()
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}`
} }
onCleanup(disposeAll)
const prune = () => {
while (cache.size > MAX_PROMPT_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createPromptSession(dir, id),
dispose,
}))
cache.set(key, entry)
prune()
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
return { return {
ready, ready: () => session().ready(),
current: createMemo(() => store.prompt), current: () => session().current(),
cursor: createMemo(() => store.cursor), cursor: () => session().cursor(),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), dirty: () => session().dirty(),
context: { context: {
activeTab: createMemo(() => store.context.activeTab), activeTab: () => session().context.activeTab(),
items: createMemo(() => store.context.items), items: () => session().context.items(),
addActive() { addActive: () => session().context.addActive(),
setStore("context", "activeTab", true) removeActive: () => session().context.removeActive(),
}, add: (item: ContextItem) => session().context.add(item),
removeActive() { remove: (key: string) => session().context.remove(key),
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(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
}, },
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
reset: () => session().reset(),
} }
}, },
}) })
+3 -1
View File
@@ -1,6 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
@@ -20,9 +21,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
[key in Event["type"]]: Extract<Event, { type: key }> [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) emitter.emit(event.type, event)
}) })
onCleanup(unsub)
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url } return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
}, },
+43 -21
View File
@@ -1,9 +1,9 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" 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 { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
type StoredProject = { worktree: string; expanded: boolean } type StoredProject = { worktree: string; expanded: boolean }
@@ -35,7 +35,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const platform = usePlatform() const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"server.v3", Persist.global("server", ["server.v3"]),
createStore({ createStore({
list: [] as string[], list: [] as string[],
projects: {} as Record<string, StoredProject[]>, projects: {} as Record<string, StoredProject[]>,
@@ -91,27 +91,49 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!active()) const isReady = createMemo(() => ready() && !!active())
const [healthy, { refetch }] = createResource( const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
() => active() || undefined,
async (url) => {
if (!url) return
const sdk = createOpencodeClient({ const check = (url: string) => {
baseUrl: url, const sdk = createOpencodeClient({
fetch: platform.fetch, baseUrl: url,
signal: AbortSignal.timeout(3000), fetch: platform.fetch,
}) signal: AbortSignal.timeout(3000),
return sdk.global })
.health() return sdk.global
.then((x) => x.data?.healthy === true) .health()
.catch(() => false) .then((x) => x.data?.healthy === true)
}, .catch(() => false)
) }
createEffect(() => { createEffect(() => {
if (!active()) return const url = active()
const interval = setInterval(() => refetch(), 10_000) if (!url) return
onCleanup(() => clearInterval(interval))
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())) const origin = createMemo(() => projectsKey(active()))
+160 -54
View File
@@ -1,5 +1,5 @@
import { batch, createMemo } from "solid-js" import { batch, createMemo } from "solid-js"
import { produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -14,6 +14,76 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sdk = useSDK() const sdk = useSDK()
const [store, setStore] = globalSync.child(sdk.directory) const [store, setStore] = globalSync.child(sdk.directory)
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
const chunk = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const limitFor = (count: number) => {
if (count <= chunk) return chunk
return Math.ceil(count / chunk) * chunk
}
const hydrateMessages = (sessionID: string) => {
if (meta.limit[sessionID] !== undefined) return
const messages = store.message[sessionID]
if (!messages) return
const limit = limitFor(messages.length)
setMeta("limit", sessionID, limit)
setMeta("complete", sessionID, messages.length < limit)
}
const loadMessages = async (sessionID: string, limit: number) => {
if (meta.loading[sessionID]) return
setMeta("loading", sessionID, true)
await retry(() => sdk.client.session.messages({ sessionID, limit }))
.then((messages) => {
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
for (const message of items) {
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
setMeta("limit", sessionID, limit)
setMeta("complete", sessionID, next.length < limit)
})
})
.finally(() => {
setMeta("loading", sessionID, false)
})
}
return { return {
data: store, data: store,
@@ -30,11 +100,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined return undefined
}, },
session: { session: {
get(sessionID: string) { get: getSession,
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
},
addOptimisticMessage(input: { addOptimisticMessage(input: {
sessionID: string sessionID: string
messageID: string messageID: string
@@ -66,58 +132,98 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}), }),
) )
}, },
async sync(sessionID: string, _isRetry = false) { async sync(sessionID: string) {
const [session, messages, todo, diff] = await Promise.all([ const hasSession = getSession(sessionID) !== undefined
retry(() => sdk.client.session.get({ sessionID })), hydrateMessages(sessionID)
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })),
])
batch(() => { const hasMessages = store.message[sessionID] !== undefined
setStore( if (hasSession && hasMessages) return
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = session.data!
return
}
draft.splice(match.index, 0, session.data!)
}),
)
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" })) const pending = inflight.get(sessionID)
setStore( if (pending) return pending
"message",
sessionID,
reconcile(
(messages.data ?? [])
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
for (const message of messages.data ?? []) { const limit = meta.limit[sessionID] ?? chunk
if (!message?.info?.id) continue
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) const sessionReq = hasSession
}) ? Promise.resolve()
: retry(() => sdk.client.session.get({ sessionID })).then((session) => {
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
const promise = Promise.all([sessionReq, messagesReq])
.then(() => {})
.finally(() => {
inflight.delete(sessionID)
})
inflight.set(sessionID, promise)
return promise
},
async diff(sessionID: string) {
if (store.session_diff[sessionID] !== undefined) return
const pending = inflightDiff.get(sessionID)
if (pending) return pending
const promise = retry(() => sdk.client.session.diff({ sessionID }))
.then((diff) => {
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
})
.finally(() => {
inflightDiff.delete(sessionID)
})
inflightDiff.set(sessionID, promise)
return promise
},
async todo(sessionID: string) {
if (store.todo[sessionID] !== undefined) return
const pending = inflightTodo.get(sessionID)
if (pending) return pending
const promise = retry(() => sdk.client.session.todo({ sessionID }))
.then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
})
.finally(() => {
inflightTodo.delete(sessionID)
})
inflightTodo.set(sessionID, promise)
return promise
},
history: {
more(sessionID: string) {
if (store.message[sessionID] === undefined) return false
if (meta.limit[sessionID] === undefined) return false
if (meta.complete[sessionID]) return false
return true
},
loading(sessionID: string) {
return meta.loading[sessionID] ?? false
},
async loadMore(sessionID: string, count = chunk) {
if (meta.loading[sessionID]) return
if (meta.complete[sessionID]) return
const current = meta.limit[sessionID] ?? chunk
await loadMessages(sessionID, current + count)
},
}, },
fetch: async (count = 10) => { fetch: async (count = 10) => {
setStore("limit", (x) => x + count) setStore("limit", (x) => x + count)
+163 -96
View File
@@ -1,9 +1,9 @@
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js" import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
export type LocalPTY = { export type LocalPTY = {
id: string id: string
@@ -14,108 +14,175 @@ export type LocalPTY = {
scrollY?: number scrollY?: number
} }
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
type TerminalSession = ReturnType<typeof createTerminalSession>
type TerminalCacheEntry = {
value: TerminalSession
dispose: VoidFunction
}
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "terminal", [legacy]),
createStore<{
active?: string
all: LocalPTY[]
}>({
all: [],
}),
)
return {
ready,
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
sdk.client.pty
.create({ title: `Terminal ${store.all.length + 1}` })
.then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
},
async close(id: string) {
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
}
})
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
}
}
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
name: "Terminal", name: "Terminal",
gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const params = useParams() const params = useParams()
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) const cache = new Map<string, TerminalCacheEntry>()
const [store, setStore, _, ready] = persisted( const disposeAll = () => {
name(), for (const entry of cache.values()) {
createStore<{ entry.dispose()
active?: string }
all: LocalPTY[] cache.clear()
}>({ }
all: [],
}), onCleanup(disposeAll)
)
const prune = () => {
while (cache.size > MAX_TERMINAL_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createTerminalSession(sdk, dir, id),
dispose,
}))
cache.set(key, entry)
prune()
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
return { return {
ready, ready: () => session().ready(),
all: createMemo(() => Object.values(store.all)), all: () => session().all(),
active: createMemo(() => store.active), active: () => session().active(),
new() { new: () => session().new(),
sdk.client.pty update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
.create({ title: `Terminal ${store.all.length + 1}` }) clone: (id: string) => session().clone(id),
.then((pty) => { open: (id: string) => session().open(id),
const id = pty.data?.id close: (id: string) => session().close(id),
if (!id) return move: (id: string, to: number) => session().move(id, to),
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
},
async close(id: string) {
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
}
})
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
} }
}, },
}) })
+4 -2
View File
@@ -1,6 +1,6 @@
// @refresh reload // @refresh reload
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { App } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform" import { Platform, PlatformProvider } from "@/context/platform"
import pkg from "../package.json" import pkg from "../package.json"
@@ -55,7 +55,9 @@ const platform: Platform = {
render( render(
() => ( () => (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<App /> <AppBaseProviders>
<AppInterface />
</AppBaseProviders>
</PlatformProvider> </PlatformProvider>
), ),
root!, root!,
+1 -1
View File
@@ -1,2 +1,2 @@
export { PlatformProvider, type Platform } from "./context/platform" export { PlatformProvider, type Platform } from "./context/platform"
export { App } from "./app" export { AppBaseProviders, AppInterface } from "./app"
+12 -2
View File
@@ -1,5 +1,5 @@
import { createMemo, Show, type ParentProps } from "solid-js" 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 { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
@@ -10,6 +10,7 @@ import { iife } from "@opencode-ai/util/iife"
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const navigate = useNavigate()
const directory = createMemo(() => { const directory = createMemo(() => {
return base64Decode(params.dir!) return base64Decode(params.dir!)
}) })
@@ -26,8 +27,17 @@ export default function Layout(props: ParentProps) {
response: "once" | "always" | "reject" response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input) }) => sdk.client.permission.respond(input)
const navigateToSession = (sessionID: string) => {
navigate(`/${params.dir}/session/${sessionID}`)
}
return ( return (
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}> <DataProvider
data={sync.data}
directory={directory()}
onPermissionRespond={respond}
onNavigateToSession={navigateToSession}
>
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider> </DataProvider>
) )
+224 -24
View File
@@ -1,4 +1,5 @@
import { import {
batch,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
@@ -31,7 +32,7 @@ import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client" import { Session } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { createStore, produce } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { import {
DragDropProvider, DragDropProvider,
DragDropSensors, DragDropSensors,
@@ -47,6 +48,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -55,6 +57,7 @@ import { DialogEditProject } from "@/components/dialog-edit-project"
import { DialogSelectServer } from "@/components/dialog-select-server" import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
@@ -170,7 +173,7 @@ export default function Layout(props: ParentProps) {
if (e.details?.type !== "permission.asked") return if (e.details?.type !== "permission.asked") return
const directory = e.name const directory = e.name
const perm = e.details.properties const perm = e.details.properties
if (permission.autoResponds(perm)) return if (permission.autoResponds(perm, directory)) return
const [store] = globalSync.child(directory) const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === perm.sessionID) const session = store.session.find((s) => s.id === perm.sessionID)
@@ -266,24 +269,170 @@ export default function Layout(props: ParentProps) {
} }
} }
function projectSessions(directory: string) { const currentProject = createMemo(() => {
if (!directory) return [] const directory = params.dir ? base64Decode(params.dir) : undefined
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions) if (!directory) return
return (sessions ?? []).filter((s) => !s.parentID) 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(() => { const currentSessions = createMemo(() => projectSessions(currentProject()))
if (!params.dir) return []
const directory = base64Decode(params.dir) type PrefetchQueue = {
return projectSessions(directory) inflight: Set<string>
pending: string[]
pendingSet: Set<string>
running: number
}
const prefetchChunk = 200
const prefetchConcurrency = 1
const prefetchPendingLimit = 6
const prefetchToken = { value: 0 }
const prefetchQueues = new Map<string, PrefetchQueue>()
createEffect(() => {
params.dir
globalSDK.url
prefetchToken.value += 1
for (const q of prefetchQueues.values()) {
q.pending.length = 0
q.pendingSet.clear()
}
})
const queueFor = (directory: string) => {
const existing = prefetchQueues.get(directory)
if (existing) return existing
const created: PrefetchQueue = {
inflight: new Set(),
pending: [],
pendingSet: new Set(),
running: 0,
}
prefetchQueues.set(directory, created)
return created
}
const prefetchMessages = (directory: string, sessionID: string, token: number) => {
const [, setStore] = globalSync.child(directory)
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
for (const message of items) {
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
})
.catch(() => undefined)
}
const pumpPrefetch = (directory: string) => {
const q = queueFor(directory)
if (q.running >= prefetchConcurrency) return
const sessionID = q.pending.shift()
if (!sessionID) return
q.pendingSet.delete(sessionID)
q.inflight.add(sessionID)
q.running += 1
const token = prefetchToken.value
void prefetchMessages(directory, sessionID, token).finally(() => {
q.running -= 1
q.inflight.delete(sessionID)
pumpPrefetch(directory)
})
}
const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
const directory = session.directory
if (!directory) return
const [store] = globalSync.child(directory)
if (store.message[session.id] !== undefined) return
const q = queueFor(directory)
if (q.inflight.has(session.id)) return
if (q.pendingSet.has(session.id)) return
if (priority === "high") q.pending.unshift(session.id)
if (priority !== "high") q.pending.push(session.id)
q.pendingSet.add(session.id)
while (q.pending.length > prefetchPendingLimit) {
const dropped = q.pending.pop()
if (!dropped) continue
q.pendingSet.delete(dropped)
}
pumpPrefetch(directory)
}
createEffect(() => {
const sessions = currentSessions()
const id = params.id
if (!id) {
const first = sessions[0]
if (first) prefetchSession(first)
const second = sessions[1]
if (second) prefetchSession(second)
return
}
const index = sessions.findIndex((s) => s.id === id)
if (index === -1) return
const next = sessions[index + 1]
if (next) prefetchSession(next)
const prev = sessions[index - 1]
if (prev) prefetchSession(prev)
}) })
function navigateSessionByOffset(offset: number) { function navigateSessionByOffset(offset: number) {
const projects = layout.projects.list() const projects = layout.projects.list()
if (projects.length === 0) return if (projects.length === 0) return
const currentDirectory = params.dir ? base64Decode(params.dir) : undefined const project = currentProject()
const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1 const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1
if (projectIndex === -1) { if (projectIndex === -1) {
const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1] const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
@@ -303,6 +452,27 @@ export default function Layout(props: ParentProps) {
if (targetIndex >= 0 && targetIndex < sessions.length) { if (targetIndex >= 0 && targetIndex < sessions.length) {
const session = sessions[targetIndex] const session = sessions[targetIndex]
const next = sessions[targetIndex + 1]
const prev = sessions[targetIndex - 1]
if (offset > 0) {
if (next) prefetchSession(next, "high")
if (prev) prefetchSession(prev)
}
if (offset < 0) {
if (prev) prefetchSession(prev, "high")
if (next) prefetchSession(next)
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
})
}
navigateToSession(session) navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id)) queueMicrotask(() => scrollToSession(session.id))
return return
@@ -312,14 +482,34 @@ export default function Layout(props: ParentProps) {
const nextProject = projects[nextProjectIndex] const nextProject = projects[nextProjectIndex]
if (!nextProject) return if (!nextProject) return
const nextProjectSessions = projectSessions(nextProject.worktree) const nextProjectSessions = projectSessions(nextProject)
if (nextProjectSessions.length === 0) { if (nextProjectSessions.length === 0) {
navigateToProject(nextProject.worktree) navigateToProject(nextProject.worktree)
return return
} }
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1] const index = offset > 0 ? 0 : nextProjectSessions.length - 1
navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`) const targetSession = nextProjectSessions[index]
const nextSession = nextProjectSessions[index + 1]
const prevSession = nextProjectSessions[index - 1]
if (offset > 0) {
if (nextSession) prefetchSession(nextSession, "high")
}
if (offset < 0) {
if (prevSession) prefetchSession(prevSession, "high")
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(targetSession.directory),
from: params.id,
to: targetSession.id,
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
})
}
navigateToSession(targetSession)
queueMicrotask(() => scrollToSession(targetSession.id)) queueMicrotask(() => scrollToSession(targetSession.id))
} }
@@ -465,7 +655,7 @@ export default function Layout(props: ParentProps) {
function navigateToSession(session: Session | undefined) { function navigateToSession(session: Session | undefined) {
if (!session) return if (!session) return
navigate(`/${params.dir}/session/${session?.id}`) navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
layout.mobileSidebar.hide() layout.mobileSidebar.hide()
} }
@@ -514,7 +704,8 @@ export default function Layout(props: ParentProps) {
const id = params.id const id = params.id
setStore("lastSession", directory, id) setStore("lastSession", directory, id)
notification.session.markViewed(id) notification.session.markViewed(id)
untrack(() => layout.projects.expand(directory)) const project = currentProject()
untrack(() => layout.projects.expand(project?.worktree ?? directory))
requestAnimationFrame(() => scrollToSession(id)) requestAnimationFrame(() => scrollToSession(id))
}) })
@@ -644,13 +835,13 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id)) const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const [sessionStore] = globalSync.child(props.session.directory)
const hasPermissions = createMemo(() => { const hasPermissions = createMemo(() => {
const store = globalSync.child(props.project.worktree)[0] const permissions = sessionStore.permission?.[props.session.id] ?? []
const permissions = store.permission?.[props.session.id] ?? []
if (permissions.length > 0) return true 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) { for (const child of childSessions) {
const childPermissions = store.permission?.[child.id] ?? [] const childPermissions = sessionStore.permission?.[child.id] ?? []
if (childPermissions.length > 0) return true if (childPermissions.length > 0) return true
} }
return false return false
@@ -658,7 +849,7 @@ export default function Layout(props: ParentProps) {
const isWorking = createMemo(() => { const isWorking = createMemo(() => {
if (props.session.id === params.id) return false if (props.session.id === params.id) return false
if (hasPermissions()) 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 status?.type === "busy" || status?.type === "retry"
}) })
return ( return (
@@ -672,6 +863,8 @@ export default function Layout(props: ParentProps) {
<A <A
href={`${props.slug}/session/${props.session.id}`} href={`${props.slug}/session/${props.session.id}`}
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1" class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
> >
<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"> <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 <span
@@ -759,6 +952,10 @@ export default function Layout(props: ParentProps) {
const isExpanded = createMemo(() => const isExpanded = createMemo(() =>
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded, 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) => { const handleOpenChange = (open: boolean) => {
if (props.mobile) { if (props.mobile) {
if (open) mobileProjects.expand(props.project.worktree) if (open) mobileProjects.expand(props.project.worktree)
@@ -777,7 +974,10 @@ export default function Layout(props: ParentProps) {
<Button <Button
as={"div"} as={"div"}
variant="ghost" 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"> <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
<ProjectAvatar <ProjectAvatar
@@ -861,7 +1061,7 @@ export default function Layout(props: ParentProps) {
</Collapsible> </Collapsible>
</Match> </Match>
<Match when={true}> <Match when={true}>
<Tooltip placement="right" value={props.project.worktree}> <Tooltip placement="right" value={getFilename(props.project.worktree)}>
<ProjectVisual project={props.project} /> <ProjectVisual project={props.project} />
</Tooltip> </Tooltip>
</Match> </Match>
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
type Nav = {
id: string
dir?: string
from?: string
to: string
trigger?: string
start: number
marks: Record<string, number>
logged: boolean
timer?: ReturnType<typeof setTimeout>
}
const dev = import.meta.env.DEV
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
const now = () => performance.now()
const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)
const navs = new Map<string, Nav>()
const pending = new Map<string, string>()
const active = new Map<string, string>()
const required = [
"session:params",
"session:data-ready",
"session:first-turn-mounted",
"storage:prompt-ready",
"storage:terminal-ready",
"storage:file-view-ready",
]
function flush(id: string, reason: "complete" | "timeout") {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
nav.logged = true
if (nav.timer) clearTimeout(nav.timer)
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
const base = nav.marks[baseName] ?? nav.start
const ms = Object.fromEntries(
Object.entries(nav.marks)
.slice()
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
)
console.log(
"perf.session-nav " +
JSON.stringify({
type: "perf.session-nav.v0",
id: nav.id,
dir: nav.dir,
from: nav.from,
to: nav.to,
trigger: nav.trigger,
base: baseName,
reason,
ms,
}),
)
navs.delete(id)
}
function maybeFlush(id: string) {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
if (!required.every((name) => nav.marks[name] !== undefined)) return
flush(id, "complete")
}
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
const existing = navs.get(id)
if (existing) return existing
const nav: Nav = {
...data,
marks: {},
logged: false,
}
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
navs.set(id, nav)
return nav
}
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
if (!dev) return
const id = uid()
const start = now()
const nav = ensure(id, { ...input, id, start })
nav.marks["navigate:start"] = start
pending.set(key(input.dir, input.to), id)
return id
}
export function navParams(input: { dir?: string; from?: string; to: string }) {
if (!dev) return
const k = key(input.dir, input.to)
const pendingId = pending.get(k)
if (pendingId) pending.delete(k)
const id = pendingId ?? uid()
const start = now()
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
nav.marks["session:params"] = start
active.set(k, id)
maybeFlush(id)
return id
}
export function navMark(input: { dir?: string; to: string; name: string }) {
if (!dev) return
const id = active.get(key(input.dir, input.to))
if (!id) return
const nav = navs.get(id)
if (!nav) return
if (nav.marks[input.name] !== undefined) return
nav.marks[input.name] = now()
maybeFlush(id)
}
+223 -5
View File
@@ -1,17 +1,235 @@
import { usePlatform } from "@/context/platform" 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 { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store" import type { SetStoreFunction, Store } from "solid-js/store"
type InitType = Promise<string> | string | null type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>] type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> { 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 platform = usePlatform()
const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage }) 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 isAsync = init instanceof Promise
const [ready] = createResource( const [ready] = createResource(
() => init, () => init,
+184 -29
View File
@@ -1,47 +1,202 @@
import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2" import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { Prompt, FileAttachmentPart } from "@/context/prompt" 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. * Extract prompt content from message parts for restoring into the prompt input.
* This is used by undo to restore the original user prompt. * This is used by undo to restore the original user prompt.
*/ */
export function extractPromptFromParts(parts: Part[]): Prompt { export function extractPromptFromParts(parts: Part[], opts?: { directory?: string }): Prompt {
const result: Prompt = [] const textPart = textPartValue(parts)
let position = 0 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) { for (const part of parts) {
if (part.type === "text") { if (part.type === "file") {
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") {
const filePart = part as FilePart const filePart = part as FilePart
if (filePart.source?.type === "file") { const sourceText = filePart.source?.text
const path = filePart.source.path if (sourceText) {
const content = "@" + path const value = sourceText.value
const attachment: FileAttachmentPart = { const start = sourceText.start
type: "file", const end = sourceText.end
path, let path = value
content, if (value.startsWith("@")) path = value.slice(1)
start: position, if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
end: position + content.length, path = filePart.source.path
} }
result.push(attachment) inline.push({
position += content.length 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) { if (result.length === 0) {
result.push({ type: "text", content: "", start: 0, end: 0 }) result.push({ type: "text", content: "", start: 0, end: 0 })
} }
return result if (images.length === 0) return result
return [...result, ...images]
} }
+6
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])
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.2", "version": "1.1.8",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+2 -2
View File
@@ -9,8 +9,8 @@ export const config = {
github: { github: {
repoUrl: "https://github.com/anomalyco/opencode", repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: { starsFormatted: {
compact: "45K", compact: "50K",
full: "45,000", full: "50,000",
}, },
}, },
@@ -2,6 +2,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server" import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js" 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 { 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 { Identifier } from "@opencode-ai/console-core/identifier.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js" import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { Actor } from "@opencode-ai/console-core/actor.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)) .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) => { .then((message) => {
return Response.json({ message: message ?? "done" }, { status: 200 }) return Response.json({ message: message ?? "done" }, { status: 200 })
@@ -0,0 +1,8 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
}
@@ -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>
)
}
@@ -2,19 +2,23 @@ import { MonthlyLimitSection } from "./monthly-limit-section"
import { BillingSection } from "./billing-section" import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section" import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section" import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { Show } from "solid-js" import { Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router" import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common" import { queryBillingInfo, querySessionInfo } from "../../common"
export default function () { export default function () {
const params = useParams() const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id!)) const sessionInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!)) const billingInfo = createAsync(() => queryBillingInfo(params.id!))
return ( return (
<div data-page="workspace-[id]"> <div data-page="workspace-[id]">
<div data-slot="sections"> <div data-slot="sections">
<Show when={userInfo()?.isAdmin}> <Show when={sessionInfo()?.isAdmin}>
<Show when={billingInfo()?.subscriptionID}>
<BlackSection />
</Show>
<BillingSection /> <BillingSection />
<Show when={billingInfo()?.customerID}> <Show when={billingInfo()?.customerID}>
<ReloadSection /> <ReloadSection />
@@ -45,6 +45,19 @@
text-decoration: line-through; 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 { tbody tr {
@@ -61,13 +74,17 @@
} }
th { th {
&:nth-child(2) /* Payment ID */ { &:nth-child(2)
/* Payment ID */ {
display: none; display: none;
} }
} }
td { td {
&:nth-child(2) /* Payment ID */ { &:nth-child(2)
/* Payment ID */ {
display: none; display: none;
} }
} }
@@ -77,6 +77,7 @@ export function PaymentSection() {
<For each={payments()!}> <For each={payments()!}>
{(payment) => { {(payment) => {
const date = new Date(payment.timeCreated) const date = new Date(payment.timeCreated)
const isCredit = !payment.paymentID
return ( return (
<tr> <tr>
<td data-slot="payment-date" title={formatDateUTC(date)}> <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-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}> <td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((payment.amount ?? 0) / 100000000).toFixed(2)} ${((payment.amount ?? 0) / 100000000).toFixed(2)}
{isCredit ? " (credit)" : ""}
</td> </td>
<td data-slot="payment-receipt"> <td data-slot="payment-receipt">
<button {isCredit ? (
onClick={async () => { <span>-</span>
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!) ) : (
if (receiptUrl) { <button
window.open(receiptUrl, "_blank") onClick={async () => {
} const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
}} if (receiptUrl) {
data-slot="receipt-button" window.open(receiptUrl, "_blank")
> }
View }}
</button> data-slot="receipt-button"
>
View
</button>
)}
</td> </td>
</tr> </tr>
) )
@@ -169,7 +169,9 @@ export function UsageSection() {
</Show> </Show>
</div> </div>
</td> </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> </tr>
) )
}} }}
@@ -1,6 +1,13 @@
export class AuthError extends Error {} export class AuthError extends Error {}
export class CreditsError extends Error {} export class CreditsError extends Error {}
export class MonthlyLimitError 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 UserLimitError extends Error {}
export class ModelError extends Error {} export class ModelError extends Error {}
export class RateLimitError extends Error {} export class RateLimitError extends Error {}
@@ -8,11 +8,20 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ZenData } from "@opencode-ai/console-core/model.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 { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.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 { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger" 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 { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic" import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google" import { googleHelper } from "./provider/google"
@@ -69,13 +78,13 @@ export async function handler(
const dataDumper = createDataDumper(sessionId, requestId, projectId) const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient) const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
const isTrial = await trialLimiter?.isTrial() const isTrial = await trialLimiter?.isTrial()
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip) const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip)
await rateLimiter?.check() await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId) const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId)
const stickyProvider = await stickyTracker?.get() const stickyProvider = await stickyTracker?.get()
const authInfo = await authenticate(modelInfo)
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const authInfo = await authenticate(modelInfo)
const providerInfo = selectProvider( const providerInfo = selectProvider(
zenData, zenData,
authInfo, 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 // Store model request
dataDumper?.provideModel(providerInfo.storeModel) dataDumper?.provideModel(providerInfo.storeModel)
@@ -172,8 +181,8 @@ export async function handler(
const tokensInfo = providerInfo.normalizeUsage(json.usage) const tokensInfo = providerInfo.normalizeUsage(json.usage)
await trialLimiter?.track(tokensInfo) await trialLimiter?.track(tokensInfo)
await rateLimiter?.track() await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo) const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo) await reload(authInfo, costInfo)
return new Response(body, { return new Response(body, {
status: resStatus, status: resStatus,
statusText: res.statusText, statusText: res.statusText,
@@ -206,8 +215,8 @@ export async function handler(
if (usage) { if (usage) {
const tokensInfo = providerInfo.normalizeUsage(usage) const tokensInfo = providerInfo.normalizeUsage(usage)
await trialLimiter?.track(tokensInfo) await trialLimiter?.track(tokensInfo)
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo) const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo) await reload(authInfo, costInfo)
} }
c.close() c.close()
return return
@@ -279,14 +288,19 @@ export async function handler(
{ status: 401 }, { 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( return new Response(
JSON.stringify({ JSON.stringify({
type: "error", type: "error",
error: { type: error.constructor.name, message: error.message }, error: { type: error.constructor.name, message: error.message },
}), }),
{ status: 429 }, { status: 429, headers },
) )
}
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -392,6 +406,7 @@ export async function handler(
monthlyUsage: BillingTable.monthlyUsage, monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
reloadTrigger: BillingTable.reloadTrigger, reloadTrigger: BillingTable.reloadTrigger,
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
}, },
user: { user: {
id: UserTable.id, id: UserTable.id,
@@ -399,6 +414,13 @@ export async function handler(
monthlyUsage: UserTable.monthlyUsage, monthlyUsage: UserTable.monthlyUsage,
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated, timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
}, },
subscription: {
timeSubscribed: UserTable.timeSubscribed,
subIntervalUsage: UserTable.subIntervalUsage,
subMonthlyUsage: UserTable.subMonthlyUsage,
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
},
provider: { provider: {
credentials: ProviderTable.credentials, credentials: ProviderTable.credentials,
}, },
@@ -426,6 +448,7 @@ export async function handler(
logger.metric({ logger.metric({
api_key: data.apiKey, api_key: data.apiKey,
workspace: data.workspaceID, workspace: data.workspaceID,
isSubscription: data.subscription.timeSubscribed ? true : false,
}) })
return { return {
@@ -433,6 +456,7 @@ export async function handler(
workspaceID: data.workspaceID, workspaceID: data.workspaceID,
billing: data.billing, billing: data.billing,
user: data.user, user: data.user,
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
provider: data.provider, provider: data.provider,
isFree: FREE_WORKSPACES.includes(data.workspaceID), isFree: FREE_WORKSPACES.includes(data.workspaceID),
isDisabled: !!data.timeDisabled, isDisabled: !!data.timeDisabled,
@@ -445,6 +469,64 @@ export async function handler(
if (authInfo.isFree) return if (authInfo.isFree) return
if (modelInfo.allowAnonymous) 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 const billing = authInfo.billing
if (!billing.paymentMethodID) if (!billing.paymentMethodID)
throw new CreditsError( throw new CreditsError(
@@ -462,29 +544,25 @@ export async function handler(
billing.monthlyLimit && billing.monthlyLimit &&
billing.monthlyUsage && billing.monthlyUsage &&
billing.timeMonthlyUsageUpdated && billing.timeMonthlyUsageUpdated &&
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) &&
) { currentYear === billing.timeMonthlyUsageUpdated.getUTCFullYear() &&
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear() currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth() )
if (currentYear === dateYear && currentMonth === dateMonth) throw new MonthlyLimitError(
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`,
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`, )
)
}
if ( if (
authInfo.user.monthlyLimit && authInfo.user.monthlyLimit &&
authInfo.user.monthlyUsage && authInfo.user.monthlyUsage &&
authInfo.user.timeMonthlyUsageUpdated && authInfo.user.timeMonthlyUsageUpdated &&
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) &&
) { currentYear === authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() &&
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth() )
if (currentYear === dateYear && currentMonth === dateMonth) throw new UserLimitError(
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`,
`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) { function validateModelSettings(authInfo: AuthInfo) {
@@ -559,61 +637,110 @@ export async function handler(
if (!authInfo) return if (!authInfo) return
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent) const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => { await Database.use((db) =>
await tx.insert(UsageTable).values({ Promise.all([
workspaceID: authInfo.workspaceID, db.insert(UsageTable).values({
id: Identifier.create("usage"), workspaceID: authInfo.workspaceID,
model: modelInfo.id, id: Identifier.create("usage"),
provider: providerInfo.id, model: modelInfo.id,
inputTokens, provider: providerInfo.id,
outputTokens, inputTokens,
reasoningTokens, outputTokens,
cacheReadTokens, reasoningTokens,
cacheWrite5mTokens, cacheReadTokens,
cacheWrite1hTokens, cacheWrite5mTokens,
cost, cacheWrite1hTokens,
keyID: authInfo.apiKeyId, cost,
}) keyID: authInfo.apiKeyId,
await tx enrichment: authInfo.subscription ? { plan: "sub" } : undefined,
.update(BillingTable) }),
.set({ db
balance: sql`${BillingTable.balance} - ${cost}`, .update(KeyTable)
monthlyUsage: sql` .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 CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost} WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost} ELSE ${cost}
END END
`, `,
timeMonthlyUsageUpdated: sql`now()`, timeMonthlyUsageUpdated: sql`now()`,
}) })
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)) .where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
await tx db
.update(UserTable) .update(UserTable)
.set({ .set({
monthlyUsage: sql` monthlyUsage: sql`
CASE CASE
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost} WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
ELSE ${cost} ELSE ${cost}
END END
`, `,
timeMonthlyUsageUpdated: sql`now()`, timeMonthlyUsageUpdated: sql`now()`,
}) })
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))) .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))),
) )
return { costInMicroCents: cost }
} }
async function reload(authInfo: AuthInfo) { async function reload(authInfo: AuthInfo, costInfo: Awaited<ReturnType<typeof trackUsage>>) {
if (!authInfo) return if (!authInfo) return
if (authInfo.isFree) return if (authInfo.isFree) return
if (authInfo.provider?.credentials) 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) => const lock = await Database.use((tx) =>
tx tx
@@ -625,10 +752,7 @@ export async function handler(
and( and(
eq(BillingTable.workspaceID, authInfo.workspaceID), eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true), eq(BillingTable.reload, true),
lt( lt(BillingTable.balance, reloadTrigger),
BillingTable.balance,
centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)), or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
), ),
), ),
@@ -1,28 +1,34 @@
import { Resource } from "@opencode-ai/console-resource" import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error" import { RateLimitError } from "./error"
import { logger } from "./logger" import { logger } from "./logger"
export function createRateLimiter(model: string, limit: number | undefined, ip: string) { export function createRateLimiter(limit: number | undefined, rawIp: string) {
if (!limit) return if (!limit) return
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now() const now = Date.now()
const currKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now)}` const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
const prevKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now - 3_600_000)}`
let currRate: number
let prevRate: number
return { return {
track: async () => { track: async () => {
await Resource.GatewayKv.put(currKey, currRate + 1, { expirationTtl: 3600 }) await Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values({ ip, interval: intervals[0], count: 1 })
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
)
}, },
check: async () => { check: async () => {
const values = await Resource.GatewayKv.get([currKey, prevKey]) const rows = await Database.use((tx) =>
const prevValue = values?.get(prevKey) tx
const currValue = values?.get(currKey) .select({ count: IpRateLimitTable.count })
prevRate = prevValue ? parseInt(prevValue) : 0 .from(IpRateLimitTable)
currRate = currValue ? parseInt(currValue) : 0 .where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))),
logger.debug(`rate limit ${model} prev/curr: ${prevRate}/${currRate}`) )
if (prevRate + currRate >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`)
if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
}, },
} }
} }
@@ -0,0 +1,6 @@
CREATE TABLE `ip_rate_limit` (
`ip` varchar(45) NOT NULL,
`interval` varchar(10) NOT NULL,
`count` int NOT NULL,
CONSTRAINT `ip_rate_limit_ip_interval_pk` PRIMARY KEY(`ip`,`interval`)
);
@@ -0,0 +1,7 @@
ALTER TABLE `billing` ADD `subscription_id` varchar(28);--> statement-breakpoint
ALTER TABLE `usage` ADD `data` json;--> statement-breakpoint
ALTER TABLE `user` ADD `time_subscribed` timestamp(3);--> statement-breakpoint
ALTER TABLE `user` ADD `sub_recent_usage` bigint;--> statement-breakpoint
ALTER TABLE `user` ADD `sub_monthly_usage` bigint;--> statement-breakpoint
ALTER TABLE `user` ADD `sub_time_recent_usage_updated` timestamp(3);--> statement-breakpoint
ALTER TABLE `user` ADD `sub_time_monthly_usage_updated` timestamp(3);
@@ -0,0 +1,2 @@
ALTER TABLE `user` RENAME COLUMN `sub_recent_usage` TO `sub_interval_usage`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `sub_time_recent_usage_updated` TO `sub_time_interval_usage_updated`;
@@ -0,0 +1 @@
ALTER TABLE `usage` RENAME COLUMN `data` TO `enrichment`;
@@ -0,0 +1 @@
ALTER TABLE `billing` ADD CONSTRAINT `global_subscription_id` UNIQUE(`subscription_id`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -288,6 +288,41 @@
"when": 1767584617316, "when": 1767584617316,
"tag": "0040_broken_gamora", "tag": "0040_broken_gamora",
"breakpoints": true "breakpoints": true
},
{
"idx": 41,
"version": "5",
"when": 1767732559197,
"tag": "0041_odd_misty_knight",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1767744077346,
"tag": "0042_flat_nightmare",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1767752636118,
"tag": "0043_lame_calypso",
"breakpoints": true
},
{
"idx": 44,
"version": "5",
"when": 1767759322451,
"tag": "0044_tiny_captain_midlands",
"breakpoints": true
},
{
"idx": 45,
"version": "5",
"when": 1767765497502,
"tag": "0045_cuddly_diamondback",
"breakpoints": true
} }
] ]
} }
+4 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.2", "version": "1.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -32,6 +32,9 @@
"promote-models-to-dev": "script/promote-models.ts dev", "promote-models-to-dev": "script/promote-models.ts dev",
"promote-models-to-prod": "script/promote-models.ts production", "promote-models-to-prod": "script/promote-models.ts production",
"pull-models-from-dev": "script/pull-models.ts dev", "pull-models-from-dev": "script/pull-models.ts dev",
"update-black": "script/update-black.ts",
"promote-black-to-dev": "script/promote-black.ts dev",
"promote-black-to-prod": "script/promote-black.ts production",
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,20 @@
import { Billing } from "../src/billing.js"
// get input from command line
const workspaceID = process.argv[2]
const dollarAmount = process.argv[3]
if (!workspaceID || !dollarAmount) {
console.error("Usage: bun credit-workspace.ts <workspaceID> <dollarAmount>")
process.exit(1)
}
const amountInDollars = parseFloat(dollarAmount)
if (isNaN(amountInDollars) || amountInDollars <= 0) {
console.error("Error: dollarAmount must be a positive number")
process.exit(1)
}
await Billing.grantCredit(workspaceID, amountInDollars)
console.log(`Added payment of $${amountInDollars.toFixed(2)} to workspace ${workspaceID}`)
+132 -13
View File
@@ -1,26 +1,145 @@
import { Database, eq } from "../src/drizzle/index.js" import { Database, eq, sql, inArray } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql" import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
// get input from command line // get input from command line
const email = process.argv[2] const identifier = process.argv[2]
if (!email) { if (!identifier) {
console.error("Usage: bun lookup-user.ts <email>") console.error("Usage: bun lookup-user.ts <email|workspaceID>")
process.exit(1) process.exit(1)
} }
const authData = await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.subject, email))) if (identifier.startsWith("wrk_")) {
if (authData.length === 0) { await printWorkspace(identifier)
console.error("User not found") } else {
process.exit(1) const authData = await Database.use(async (tx) =>
tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
)
if (authData.length === 0) {
console.error("Email not found")
process.exit(1)
}
if (authData.length > 1) console.warn("Multiple users found for email", identifier)
// Get all auth records for email
const accountID = authData[0].accountID
await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID)))
// Get all workspaces for this account
const users = await printTable("Workspaces", (tx) =>
tx
.select({
userID: UserTable.id,
workspaceID: UserTable.workspaceID,
workspaceName: WorkspaceTable.name,
role: UserTable.role,
})
.from(UserTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.where(eq(UserTable.accountID, accountID)),
)
// Get all payments for these workspaces
await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID)))
} }
await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID))) async function printWorkspace(workspaceID: string) {
const workspace = await Database.use((tx) =>
tx
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0]),
)
function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any[]>): Promise<any[]> { printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
await printTable("Billing", (tx) =>
tx
.select({
balance: BillingTable.balance,
customerID: BillingTable.customerID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id))
.then(
(rows) =>
rows.map((row) => ({
...row,
balance: `$${(row.balance / 100000000).toFixed(2)}`,
}))[0],
),
)
await printTable("Payments", (tx) =>
tx
.select({
amount: PaymentTable.amount,
paymentID: PaymentTable.paymentID,
invoiceID: PaymentTable.invoiceID,
timeCreated: PaymentTable.timeCreated,
timeRefunded: PaymentTable.timeRefunded,
})
.from(PaymentTable)
.where(eq(PaymentTable.workspaceID, workspace.id))
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
.limit(100)
.then((rows) =>
rows.map((row) => ({
...row,
amount: `$${(row.amount / 100000000).toFixed(2)}`,
paymentID: row.paymentID
? `https://dashboard.stripe.com/acct_1RszBH2StuRr0lbX/payments/${row.paymentID}`
: null,
})),
),
)
await printTable("Usage", (tx) =>
tx
.select({
model: UsageTable.model,
provider: UsageTable.provider,
inputTokens: UsageTable.inputTokens,
outputTokens: UsageTable.outputTokens,
reasoningTokens: UsageTable.reasoningTokens,
cacheReadTokens: UsageTable.cacheReadTokens,
cacheWrite5mTokens: UsageTable.cacheWrite5mTokens,
cacheWrite1hTokens: UsageTable.cacheWrite1hTokens,
cost: UsageTable.cost,
timeCreated: UsageTable.timeCreated,
})
.from(UsageTable)
.where(eq(UsageTable.workspaceID, workspace.id))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(10)
.then((rows) =>
rows.map((row) => ({
...row,
cost: `$${(row.cost / 100000000).toFixed(2)}`,
})),
),
)
}
function printHeader(title: string) {
console.log()
console.log("─".repeat(title.length))
console.log(`${title}`)
console.log("─".repeat(title.length))
}
function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any>): Promise<any> {
return Database.use(async (tx) => { return Database.use(async (tx) => {
const data = await callback(tx) const data = await callback(tx)
console.log(`== ${title} ==`) console.log(`\n== ${title} ==`)
console.table(data) if (data.length === 0) {
console.log("(no data)")
} else {
console.table(data)
}
return data return data
}) })
} }
@@ -0,0 +1,166 @@
import { Billing } from "../src/billing.js"
import { Database, eq, and, sql } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js"
import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js"
const workspaceID = process.argv[2]
const email = process.argv[3]
if (!workspaceID || !email) {
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
process.exit(1)
}
// Look up the Stripe customer by email
const customers = await Billing.stripe().customers.list({ email, limit: 1 })
const customer = customers.data[0]
if (!customer) {
console.error(`Error: No Stripe customer found for email ${email}`)
process.exit(1)
}
const customerID = customer.id
// Get the subscription id
const subscriptions = await Billing.stripe().subscriptions.list({ customer: customerID, limit: 1 })
const subscription = subscriptions.data[0]
if (!subscription) {
console.error(`Error: Customer ${customerID} does not have a subscription`)
process.exit(1)
}
const subscriptionID = subscription.id
// Validate the subscription is $200
const amountInCents = subscription.items.data[0]?.price.unit_amount ?? 0
if (amountInCents !== 20000) {
console.error(`Error: Subscription amount is $${amountInCents / 100}, expected $200`)
process.exit(1)
}
// Check if subscription is already tied to another workspace
const existingSubscription = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.then((rows) => rows[0]),
)
if (existingSubscription) {
console.error(
`Error: Subscription ${subscriptionID} is already tied to workspace ${existingSubscription.workspaceID}`,
)
process.exit(1)
}
// Look up the workspace billing and check if it already has a customer id or subscription
const billing = await Database.use((tx) =>
tx
.select({ customerID: BillingTable.customerID, subscriptionID: BillingTable.subscriptionID })
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (billing?.subscriptionID) {
console.error(`Error: Workspace ${workspaceID} already has a subscription: ${billing.subscriptionID}`)
process.exit(1)
}
if (billing?.customerID) {
console.warn(
`Warning: Workspace ${workspaceID} already has a customer id: ${billing.customerID}, replacing with ${customerID}`,
)
}
// Get the latest invoice and payment from the subscription
const invoices = await Billing.stripe().invoices.list({
subscription: subscriptionID,
limit: 1,
expand: ["data.payments"],
})
const invoice = invoices.data[0]
const invoiceID = invoice?.id
const paymentID = invoice?.payments?.data[0]?.payment.payment_intent as string | undefined
// Get the default payment method from the customer
const paymentMethodID = (customer.invoice_settings.default_payment_method ?? subscription.default_payment_method) as
| string
| null
const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.retrieve(paymentMethodID) : null
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
const paymentMethodType = paymentMethod?.type ?? null
// Look up the user by email via AuthTable
const auth = await Database.use((tx) =>
tx
.select({ accountID: AuthTable.accountID })
.from(AuthTable)
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
.then((rows) => rows[0]),
)
if (!auth) {
console.error(`Error: No user found with email ${email}`)
process.exit(1)
}
// Look up the user in the workspace
const user = await Database.use((tx) =>
tx
.select({ id: UserTable.id })
.from(UserTable)
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, auth.accountID)))
.then((rows) => rows[0]),
)
if (!user) {
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`)
process.exit(1)
}
// Set workspaceID in Stripe customer metadata
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
await Database.transaction(async (tx) => {
// Set customer id, subscription id, and payment method on workspace billing
await tx
.update(BillingTable)
.set({
customerID,
subscriptionID,
paymentMethodID,
paymentMethodLast4,
paymentMethodType,
})
.where(eq(BillingTable.workspaceID, workspaceID))
// Set current time as timeSubscribed on user
await tx
.update(UserTable)
.set({
timeSubscribed: sql`now()`,
})
.where(eq(UserTable.id, user.id))
// Create a row in payments table
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
customerID,
invoiceID,
paymentID,
})
})
console.log(`Successfully onboarded workspace ${workspaceID}`)
console.log(` Customer ID: ${customerID}`)
console.log(` Subscription ID: ${subscriptionID}`)
console.log(
` Payment Method: ${paymentMethodID ?? "(none)"} (${paymentMethodType ?? "unknown"} ending in ${paymentMethodLast4 ?? "????"})`,
)
console.log(` User ID: ${user.id}`)
console.log(` Invoice ID: ${invoiceID ?? "(none)"}`)
console.log(` Payment ID: ${paymentID ?? "(none)"}`)
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "path"
import { BlackData } from "../src/black"
const stage = process.argv[2]
if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1]
if (!value) throw new Error("ZEN_BLACK not found")
// validate value
BlackData.validate(JSON.parse(value))
// update the secret
await $`bun sst secret set ZEN_BLACK ${value} --stage ${stage}`
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "path"
import os from "os"
import { BlackData } from "../src/black"
const root = path.resolve(process.cwd(), "..", "..", "..")
const secrets = await $`bun sst secret list`.cwd(root).text()
// read the line starting with "ZEN_BLACK"
const lines = secrets.split("\n")
const oldValue = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1]
if (!oldValue) throw new Error("ZEN_BLACK not found")
// store the prettified json to a temp file
const filename = `black-${Date.now()}.json`
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close
await $`vim ${tempFile.name}`
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
BlackData.validate(JSON.parse(newValue))
// update the secret
await $`bun sst secret set ZEN_BLACK ${newValue}`
+19
View File
@@ -27,6 +27,7 @@ export namespace Billing {
tx tx
.select({ .select({
customerID: BillingTable.customerID, customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
paymentMethodID: BillingTable.paymentMethodID, paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType, paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4, paymentMethodLast4: BillingTable.paymentMethodLast4,
@@ -157,6 +158,24 @@ export namespace Billing {
}) })
} }
export const grantCredit = async (workspaceID: string, dollarAmount: number) => {
const amountInMicroCents = centsToMicroCents(dollarAmount * 100)
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} + ${amountInMicroCents}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: amountInMicroCents,
})
})
return amountInMicroCents
}
export const setMonthlyLimit = fn(z.number(), async (input) => { export const setMonthlyLimit = fn(z.number(), async (input) => {
return await Database.use((tx) => return await Database.use((tx) =>
tx tx
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { Resource } from "@opencode-ai/console-resource"
export namespace BlackData {
const Schema = z.object({
monthlyLimit: z.number().int(),
intervalLimit: z.number().int(),
intervalLength: z.number().int(),
})
export const validate = fn(Schema, (input) => {
return input
})
export const get = fn(z.void(), () => {
const json = JSON.parse(Resource.ZEN_BLACK.value)
return Schema.parse(json)
})
}
@@ -1,4 +1,4 @@
import { bigint, boolean, index, int, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" import { bigint, boolean, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types" import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql" import { workspaceIndexes } from "./workspace.sql"
@@ -21,8 +21,13 @@ export const BillingTable = mysqlTable(
reloadError: varchar("reload_error", { length: 255 }), reloadError: varchar("reload_error", { length: 255 }),
timeReloadError: utc("time_reload_error"), timeReloadError: utc("time_reload_error"),
timeReloadLockedTill: utc("time_reload_locked_till"), timeReloadLockedTill: utc("time_reload_locked_till"),
subscriptionID: varchar("subscription_id", { length: 28 }),
}, },
(table) => [...workspaceIndexes(table), uniqueIndex("global_customer_id").on(table.customerID)], (table) => [
...workspaceIndexes(table),
uniqueIndex("global_customer_id").on(table.customerID),
uniqueIndex("global_subscription_id").on(table.subscriptionID),
],
) )
export const PaymentTable = mysqlTable( export const PaymentTable = mysqlTable(
@@ -54,6 +59,9 @@ export const UsageTable = mysqlTable(
cacheWrite1hTokens: int("cache_write_1h_tokens"), cacheWrite1hTokens: int("cache_write_1h_tokens"),
cost: bigint("cost", { mode: "number" }).notNull(), cost: bigint("cost", { mode: "number" }).notNull(),
keyID: ulid("key_id"), keyID: ulid("key_id"),
enrichment: json("enrichment").$type<{
plan: "sub"
}>(),
}, },
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)], (table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
) )
@@ -10,3 +10,13 @@ export const IpTable = mysqlTable(
}, },
(table) => [primaryKey({ columns: [table.ip] })], (table) => [primaryKey({ columns: [table.ip] })],
) )
export const IpRateLimitTable = mysqlTable(
"ip_rate_limit",
{
ip: varchar("ip", { length: 45 }).notNull(),
interval: varchar("interval", { length: 10 }).notNull(),
count: int("count").notNull(),
},
(table) => [primaryKey({ columns: [table.ip, table.interval] })],
)
@@ -18,6 +18,12 @@ export const UserTable = mysqlTable(
monthlyLimit: int("monthly_limit"), monthlyLimit: int("monthly_limit"),
monthlyUsage: bigint("monthly_usage", { mode: "number" }), monthlyUsage: bigint("monthly_usage", { mode: "number" }),
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"), timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
// subscription
timeSubscribed: utc("time_subscribed"),
subIntervalUsage: bigint("sub_interval_usage", { mode: "number" }),
subMonthlyUsage: bigint("sub_monthly_usage", { mode: "number" }),
timeSubIntervalUsageUpdated: utc("sub_time_interval_usage_updated"),
timeSubMonthlyUsageUpdated: utc("sub_time_monthly_usage_updated"),
}, },
(table) => [ (table) => [
...workspaceIndexes(table), ...workspaceIndexes(table),
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite" "type": "sst.cloudflare.StaticSite"
"url": string "url": string
} }
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": { "ZEN_MODELS1": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.2", "version": "1.1.8",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite" "type": "sst.cloudflare.StaticSite"
"url": string "url": string
} }
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": { "ZEN_MODELS1": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.2", "version": "1.1.8",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite" "type": "sst.cloudflare.StaticSite"
"url": string "url": string
} }
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": { "ZEN_MODELS1": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
+29 -4
View File
@@ -1,7 +1,32 @@
# Tauri + Vanilla TS # OpenCode Desktop
This template should help get you started developing with Tauri in vanilla HTML, CSS and Typescript. Native OpenCode desktop app, built with Tauri v2.
## Recommended IDE Setup ## Development
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) From the repo root:
```bash
bun install
bun run --cwd packages/desktop tauri dev
```
This starts the Vite 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
```
## Build
To create a production `dist/` and build the native app bundle:
```bash
bun run --cwd packages/desktop tauri build
```
## Prerequisites
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.
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.1.2", "version": "1.1.8",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -14,6 +14,7 @@
}, },
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
+35
View File
@@ -1177,6 +1177,21 @@ dependencies = [
"new_debug_unreachable", "new_debug_unreachable",
] ]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -1184,6 +1199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -1251,6 +1267,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro", "futures-macro",
@@ -2775,8 +2792,10 @@ dependencies = [
name = "opencode-desktop" name = "opencode-desktop"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"futures",
"gtk", "gtk",
"listeners", "listeners",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -2789,6 +2808,7 @@ dependencies = [
"tauri-plugin-os", "tauri-plugin-os",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-shell", "tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-window-state", "tauri-plugin-window-state",
@@ -4636,6 +4656,21 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]] [[package]]
name = "tauri-plugin-store" name = "tauri-plugin-store"
version = "2.4.1" version = "2.4.1"
+3
View File
@@ -29,12 +29,15 @@ tauri-plugin-window-state = "2"
tauri-plugin-clipboard-manager = "2" tauri-plugin-clipboard-manager = "2"
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = "1.48.0" tokio = "1.48.0"
listeners = "0.3" listeners = "0.3"
tauri-plugin-os = "2" tauri-plugin-os = "2"
futures = "0.3.31"
semver = "1.0.27"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2" gtk = "0.18.2"
@@ -12,5 +12,19 @@
<true/> <true/>
<key>com.apple.security.cs.disable-library-validation</key> <key>com.apple.security.cs.disable-library-validation</key>
<true/> <true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.personal-information.addressbook</key>
<true/>
<key>com.apple.security.personal-information.calendars</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
</dict> </dict>
</plist> </plist>
+116
View File
@@ -0,0 +1,116 @@
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
fn get_cli_install_path() -> Option<std::path::PathBuf> {
std::env::var("HOME").ok().map(|home| {
std::path::PathBuf::from(home)
.join(CLI_INSTALL_DIR)
.join(CLI_BINARY_NAME)
})
}
pub fn get_sidecar_path() -> std::path::PathBuf {
tauri::utils::platform::current_exe()
.expect("Failed to get current exe")
.parent()
.expect("Failed to get parent dir")
.join("opencode-cli")
}
fn is_cli_installed() -> bool {
get_cli_install_path()
.map(|path| path.exists())
.unwrap_or(false)
}
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
#[tauri::command]
pub fn install_cli() -> Result<String, String> {
if cfg!(not(unix)) {
return Err("CLI installation is only supported on macOS & Linux".to_string());
}
let sidecar = get_sidecar_path();
if !sidecar.exists() {
return Err("Sidecar binary not found".to_string());
}
let temp_script = std::env::temp_dir().join("opencode-install.sh");
std::fs::write(&temp_script, INSTALL_SCRIPT)
.map_err(|e| format!("Failed to write install script: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("Failed to set script permissions: {}", e))?;
}
let output = std::process::Command::new(&temp_script)
.arg("--binary")
.arg(&sidecar)
.output()
.map_err(|e| format!("Failed to run install script: {}", e))?;
let _ = std::fs::remove_file(&temp_script);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Install script failed: {}", stderr));
}
let install_path =
get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?;
Ok(install_path.to_string_lossy().to_string())
}
pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
if cfg!(debug_assertions) {
println!("Skipping CLI sync for debug build");
return Ok(());
}
if !is_cli_installed() {
println!("No CLI installation found, skipping sync");
return Ok(());
}
let cli_path =
get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?;
let output = std::process::Command::new(&cli_path)
.arg("--version")
.output()
.map_err(|e| format!("Failed to get CLI version: {}", e))?;
if !output.status.success() {
return Err("Failed to get CLI version".to_string());
}
let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
let cli_version = semver::Version::parse(&cli_version_str)
.map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?;
let app_version = app.package_info().version.clone();
if cli_version >= app_version {
println!(
"CLI version {} is up to date (app version: {}), skipping sync",
cli_version, app_version
);
return Ok(());
}
println!(
"CLI version {} is older than app version {}, syncing",
cli_version, app_version
);
install_cli()?;
println!("Synced installed CLI");
Ok(())
}
+125 -98
View File
@@ -1,14 +1,18 @@
mod cli;
mod window_customizer; mod window_customizer;
use cli::{get_sidecar_path, install_cli, sync_cli};
use futures::FutureExt;
use std::{ use std::{
collections::VecDeque, collections::VecDeque,
net::{SocketAddr, TcpListener}, net::{SocketAddr, TcpListener},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory}; use tauri::{
use tauri_plugin_clipboard_manager::ClipboardExt; path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; WebviewWindow,
};
use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
use tokio::net::TcpSocket; use tokio::net::TcpSocket;
@@ -16,7 +20,26 @@ use tokio::net::TcpSocket;
use crate::window_customizer::PinchZoomDisablePlugin; use crate::window_customizer::PinchZoomDisablePlugin;
#[derive(Clone)] #[derive(Clone)]
struct ServerState(Arc<Mutex<Option<CommandChild>>>); struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
}
impl ServerState {
pub fn new(
child: Option<CommandChild>,
status: tokio::sync::oneshot::Receiver<Result<(), String>>,
) -> Self {
Self {
child: Arc::new(Mutex::new(child)),
status: status.shared(),
}
}
pub fn set_child(&self, child: Option<CommandChild>) {
*self.child.lock().unwrap() = child;
}
}
#[derive(Clone)] #[derive(Clone)]
struct LogState(Arc<Mutex<VecDeque<String>>>); struct LogState(Arc<Mutex<VecDeque<String>>>);
@@ -31,7 +54,7 @@ fn kill_sidecar(app: AppHandle) {
}; };
let Some(server_state) = server_state let Some(server_state) = server_state
.0 .child
.lock() .lock()
.expect("Failed to acquire mutex lock") .expect("Failed to acquire mutex lock")
.take() .take()
@@ -45,25 +68,6 @@ fn kill_sidecar(app: AppHandle) {
println!("Killed server"); println!("Killed server");
} }
#[tauri::command]
async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> {
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
let logs = log_state
.0
.lock()
.map_err(|_| "Failed to acquire log lock")?;
let log_text = logs.iter().cloned().collect::<Vec<_>>().join("");
app.clipboard()
.write_text(log_text)
.map_err(|e| format!("Failed to copy to clipboard: {}", e))?;
Ok(())
}
#[tauri::command]
async fn get_logs(app: AppHandle) -> Result<String, String> { async fn get_logs(app: AppHandle) -> Result<String, String> {
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?; let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
@@ -75,6 +79,15 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
Ok(logs.iter().cloned().collect::<Vec<_>>().join("")) Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
} }
#[tauri::command]
async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
state
.status
.clone()
.await
.map_err(|_| "Failed to get server status".to_string())?
}
fn get_sidecar_port() -> u32 { fn get_sidecar_port() -> u32 {
option_env!("OPENCODE_PORT") option_env!("OPENCODE_PORT")
.map(|s| s.to_string()) .map(|s| s.to_string())
@@ -116,11 +129,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let (mut rx, child) = { let (mut rx, child) = {
let sidecar_path = tauri::utils::platform::current_exe() let sidecar = get_sidecar_path();
.expect("Failed to get current exe")
.parent()
.expect("Failed to get parent dir")
.join("opencode-cli");
let shell = get_user_shell(); let shell = get_user_shell();
app.shell() app.shell()
.command(&shell) .command(&shell)
@@ -130,7 +139,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
.args([ .args([
"-il", "-il",
"-c", "-c",
&format!("{} serve --port={}", sidecar_path.display(), port), &format!("\"{}\" serve --port={}", sidecar.display(), port),
]) ])
.spawn() .spawn()
.expect("Failed to spawn opencode") .expect("Failed to spawn opencode")
@@ -189,6 +198,13 @@ pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
let mut builder = tauri::Builder::default() let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// Focus existing window when another instance is launched
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
let _ = window.unminimize();
}
}))
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_window_state::Builder::new().build()) .plugin(tauri_plugin_window_state::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
@@ -202,8 +218,8 @@ pub fn run() {
.plugin(PinchZoomDisablePlugin) .plugin(PinchZoomDisablePlugin)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
kill_sidecar, kill_sidecar,
copy_logs_to_clipboard, install_cli,
get_logs ensure_server_started
]) ])
.setup(move |app| { .setup(move |app| {
let app = app.handle().clone(); let app = app.handle().clone();
@@ -211,83 +227,94 @@ pub fn run() {
// Initialize log state // Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new())))); app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
tauri::async_runtime::spawn(async move { // Get port and create window immediately for faster perceived startup
let port = get_sidecar_port(); let port = get_sidecar_port();
let should_spawn_sidecar = !is_server_running(port).await; let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080));
let child = if should_spawn_sidecar { // Create window immediately with serverReady = false
let child = spawn_sidecar(&app, port); let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode")
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"#
));
let timestamp = Instant::now(); #[cfg(target_os = "macos")]
loop { {
if timestamp.elapsed() > Duration::from_secs(7) { window_builder = window_builder
let res = app.dialog() .title_bar_style(tauri::TitleBarStyle::Overlay)
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.") .hidden_title(true);
.title("Startup Failed") }
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
.blocking_show_with_result();
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") { let window = window_builder.build().expect("Failed to create window");
match copy_logs_to_clipboard(app.clone()).await {
Ok(()) => println!("Logs copied to clipboard successfully"), let (tx, rx) = tokio::sync::oneshot::channel();
Err(e) => println!("Failed to copy logs to clipboard: {}", e), app.manage(ServerState::new(None, rx));
}
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
let should_spawn_sidecar = !is_server_running(port).await;
let (child, res) = if should_spawn_sidecar {
let child = spawn_sidecar(&app, port);
let timestamp = Instant::now();
let res = loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
} }
app.exit(1);
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
if is_server_running(port).await {
// give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await; tokio::time::sleep(Duration::from_millis(10)).await;
break; if is_server_running(port).await {
} // give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await;
break Ok(());
}
};
println!("Server ready after {:?}", timestamp.elapsed());
(Some(child), res)
} else {
(None, Ok(()))
};
app.state::<ServerState>().set_child(child);
if res.is_ok() {
let _ = window.eval("window.__OPENCODE__.serverReady = true;");
} }
println!("Server ready after {:?}", timestamp.elapsed()); let _ = tx.send(res);
});
}
Some(child) {
} else { let app = app.clone();
None tauri::async_runtime::spawn(async move {
}; if let Err(e) = sync_cli(app) {
eprintln!("Failed to sync CLI: {e}");
let primary_monitor = app.primary_monitor().ok().flatten(); }
let size = primary_monitor });
.map(|m| m.size().to_logical(m.scale_factor())) }
.unwrap_or(LogicalSize::new(1920, 1080));
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode")
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"#
));
#[cfg(target_os = "macos")]
{
window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
}
window_builder.build().expect("Failed to create window");
app.manage(ServerState(Arc::new(Mutex::new(child))));
});
Ok(()) Ok(())
}); });

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