Compare commits

...

178 Commits

Author SHA1 Message Date
Aaron Iker 24ff7de33d feat: add session menu back 2026-01-08 20:47:10 +01:00
Aaron Iker f7bf25cea7 feat: provider 2026-01-08 20:46:52 +01:00
Aaron Iker 0732a2b6ce Merge branch 'dev' into new-toolbar-layout 2026-01-08 05:24:30 +01:00
Aaron Iker 74294d7200 fix: web spacing macos 2026-01-08 05:09:14 +01: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
Aaron Iker f3c62e0723 fix: smaller screens sidebar toggle 2026-01-08 03:39:20 +01:00
Aaron Iker 295d9d16fc fix: toolbar context reactivity 2026-01-08 03:13:06 +01:00
Aaron Iker 2b04837327 feat: traffic lights position mac os 2026-01-08 03:07:46 +01:00
Aaron Iker f552eea391 feat: loading session header in a portal, show in toolbar 2026-01-08 03:07:32 +01:00
Aaron Iker f23e3681b7 fix: small style fixes 2026-01-08 03:04:31 +01:00
Aaron Iker 1455976689 fix: avatar not loaded fallback 2026-01-08 03:03:26 +01: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 Alhashemi 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
jwaterwater 2d52a461a0 fix: encode non-ASCII directory paths in HTTP headers (#7145)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-07 06:46:17 -06:00
shuv 9cce0cf4f4 feat: add Night Owl theme (desktop) (#7151)
Co-authored-by: shuv <shuv@shuv.dev>
2026-01-07 06:44:27 -06:00
Brendan Allan a41c8508da desktop: go back to regular tauri cli 2026-01-07 20:42:10 +08:00
GitHub Action 4f7458b47d ignore: update download stats 2026-01-07 2026-01-07 12:04:44 +00:00
Brendan Allan 270cd05195 fix entitlements 2026-01-07 20:03:38 +08:00
Saeed Vaziry 24c933ae60 Add audio input and camera permissions to entitlements (#7117)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-01-07 17:56:46 +08:00
Brendan Allan 2b7a021ba3 desktop: appimage bundleMediaFramework 2026-01-07 17:39:35 +08:00
GitHub Action cbf87c50b9 chore: generate 2026-01-07 08:48:34 +00:00
Brendan Allan 3c375b971e desktop: use Show instead of Suspense 2026-01-07 16:47:45 +08:00
Aiden Cline 6590c1641f add truncation for all tools 2026-01-07 02:01:32 -06:00
GitHub Action 0ffe496869 chore: generate 2026-01-07 07:46:40 +00:00
Frank ce4e595881 wip: black 2026-01-07 02:45:57 -05:00
Frank e91cc7e514 wip: black 2026-01-07 02:45:57 -05:00
GitHub Action c961072d20 chore: generate 2026-01-07 07:43:43 +00:00
Aiden Cline 429240f439 ignore: add truncation funcs (#7178) 2026-01-07 01:43:06 -06:00
Github Action a0dc90bfcc Update Nix flake.lock and hashes 2026-01-07 07:36:49 +00:00
Brendan Allan 6bac501be5 add ui package to desktop 2026-01-07 15:35:32 +08:00
Daniel Polito b5be883758 Make General SubAgent not hidden (#6752) 2026-01-07 01:31:47 -06:00
Brendan Allan 0021a09ba8 try using forked tauri-cli again 2026-01-07 15:29:14 +08:00
usvimal a8c2928a87 perf: show window immediately during desktop startup (#6734)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-01-07 15:19:01 +08:00
Brendan Allan 79f6910697 desktop: use correct tauri-cli branch 2026-01-07 14:36:43 +08:00
Aiden Cline 04cea9cf11 tweak: unserializable error handling 2026-01-07 00:29:41 -06:00
Brendan Allan 61c334f1fb desktop: use regular tauri cli again 2026-01-07 14:25:25 +08:00
Spoon 85ed329318 add agent color to inline task tool ascii char and highlight agent name (#7142) 2026-01-07 00:10:00 -06:00
Brendan Allan 37decee795 use custom tauri-cli on linux 2026-01-07 13:34:18 +08:00
Frank 5d0007ade4 wip: black 2026-01-07 00:08:49 -05:00
GitHub Action 31dd9fd13a chore: generate 2026-01-07 04:35:15 +00:00
Frank 23fc675ad5 wip: black 2026-01-06 23:34:12 -05:00
Frank 22b058a33d wip: black 2026-01-06 23:34:12 -05:00
GitHub Action 939c0940aa chore: generate 2026-01-07 04:29:50 +00:00
Sewer. fd7b7eacd3 Added: Ability to hide subagents from primary agents system prompt. (#4773)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-06 22:29:17 -06:00
GitHub Action eaa0826e7f chore: generate 2026-01-07 03:42:44 +00:00
Adam f6055ad3d2 chore: fix types 2026-01-06 21:42:04 -06:00
Adam 761863ae35 chore(app): rework storage approach 2026-01-06 21:42:03 -06:00
NSPC911 dadc08ddc7 fix: escape backticks when passing to powershell (#7157) 2026-01-06 21:41:33 -06:00
Github Action d7afb01d13 Update Nix flake.lock and hashes 2026-01-07 03:20:32 +00:00
GitHub Action 5168988156 chore: generate 2026-01-07 03:19:47 +00:00
Adam 9a3bd0ade1 fix(app): hide line numbers in diffs on mobile 2026-01-06 21:18:50 -06:00
Adam 4e5b0b00b0 fix(app): session navigation with shortcuts 2026-01-06 21:18:41 -06:00
Adam 7672b722ca fix(app): permission auto-accept should hide when not needed 2026-01-06 21:18:29 -06:00
Adam 488c3502a7 fix(app): terminal selection color contrast 2026-01-06 21:18:17 -06:00
Aiden Cline a2f80f7c0d fix: add --use-system-ca flag in execArgv 2026-01-06 21:01:03 -06:00
Rafi Khardalian dee0226741 fix(tui): restore attach session lookup behavior (#7150) 2026-01-06 20:59:12 -06:00
kamkm 58aba1c797 chore: Refactor style guide to use consistent language (#7139) 2026-01-06 17:54:16 -06:00
Sebastian Herrlinger 9906d42e1c remove hallucinated keybinding 2026-01-07 00:19:56 +01:00
GitHub Action 43eefbc349 chore: generate 2026-01-06 23:06:08 +00:00
Aiden Cline 625c9dae5c fix: ensure commands listing subagent work 2026-01-06 17:05:20 -06:00
opencode b2341c2d9a release: v1.1.4
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.Tang 9197a2a7a1 docs: polish markdown wording and capitalization (#7019)
Co-authored-by: Tang Guofang <tangguofang@mychery.com>
2026-01-05 22:45:40 -06:00
Frank 8da890649f wip: zen 2026-01-05 22:48:07 -05:00
GitHub Action 21053732e7 chore: generate 2026-01-05 23:10:23 +00:00
Frank cf069dd046 wip: zen 2026-01-05 18:09:39 -05:00
Frank 4dc3cb9115 wip: zen 2026-01-05 18:09:38 -05:00
Adam 5c66c8b8e1 fix(core): filter dead worktrees 2026-01-05 16:37:18 -06:00
Adam 2ca0ae7755 fix(app): more defensive, handle no git 2026-01-05 15:12:02 -06:00
Github Action 19123b6803 Update Nix flake.lock and hashes 2026-01-05 20:19:14 +00:00
Sebastian Herrlinger 4137c66581 upgrade opentui to v0.1.69, with some text rendering performance improvements 2026-01-05 21:17:45 +01:00
GitHub Action 48d14d4cac chore: generate 2026-01-05 19:33:32 +00:00
OpeOginni 8996185f3b Feat/clickable subtask (#6846) 2026-01-05 13:32:16 -06:00
opencode 4f49967518 release: v1.1.3
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 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 Kumar c6a241e331 ci: prevent duplicate PR check from flagging current PR as duplicate (#6924)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-05 12:59:42 -06:00
Rohan Mukherjee 4b7301e8ca fix: lucent-orng bg transparency for slash commands (#6938) 2026-01-05 12:56:49 -06:00
Daniel Vélez 1bf20f0a2b docs: add description for MCP command (#6944) 2026-01-05 12:56:00 -06:00
Grégoire Morpain e3b4d4ad49 feat(bedrock): config options and authentication precedence (#6377) 2026-01-05 12:51:43 -06:00
MogamiTsuchikawa 6b207b09d6 fix(app): avoid unintended submits during IME composition (#6952) 2026-01-05 12:38:38 -06:00
Albin Groen 9771325026 feat(app): highlight collapsed active project in sidebar (#6958) 2026-01-05 12:37:46 -06:00
Albin Groen bbd1c071c4 fix(app): fix flicker and navigation when collapsing/expanding projects (#6658) 2026-01-05 11:24:49 -06:00
Daniel Polito 8e9a0c4ad0 Desktop: Install CLI (#6526)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-01-06 01:07:46 +08:00
Frank ced093e646 sync 2026-01-05 11:59:17 -05:00
Frank 283bdce358 sync 2026-01-05 11:13:59 -05:00
Dax Raad 91d5ce8bf3 tui: add system theme resolution and event handling 2026-01-05 10:38:35 -05:00
Frank 7f870cc9d4 wip: zen 2026-01-05 10:16:47 -05:00
David Hill 2cb3b0484b fix: cleaner interrupted experience (#6785)
Co-authored-by: Dax <mail@thdxr.com>
2026-01-05 09:53:19 -05:00
Frank 11b0df6b86 wip: zen 2026-01-05 06:16:04 -05:00
Frank e15af828fa zen: optimize query 2026-01-05 05:58:39 -05:00
Albin Groen 265cbaea7c fix(app): fix image dragging in project edit dialog (#6700) 2026-01-05 04:54:11 -06:00
GitHub Action d39ebbc947 chore: generate 2026-01-05 07:41:02 +00:00
Aiden Cline 06acd70670 tweak: transform 2026-01-05 01:40:15 -06:00
Aiden Cline c285304acf fix: for anthropic compat ensure empty msgs and empty reasoning is filtered out 2026-01-05 01:40:15 -06:00
222 changed files with 50513 additions and 4518 deletions
+2
View File
@@ -44,6 +44,8 @@ jobs:
{
echo "Check for duplicate PRs related to this new PR:"
echo ""
echo "CURRENT_PR_NUMBER: $PR_NUMBER"
echo ""
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
echo ""
echo "Description:"
+1 -1
View File
@@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
stats:
if: github.repository == 'sst/opencode'
if: github.repository == 'anomalyco/opencode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
+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.
IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself.
Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
If you find potential duplicates:
@@ -19,6 +21,6 @@ If you find potential duplicates:
- List them with their titles and URLs
- Briefly explain why they might be related
If no duplicates are found, say so clearly.
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.
+5
View File
@@ -45,6 +45,11 @@ export default tool({
}
const prs = result.items as PR[]
if (prs.length === 0) {
return `No other PRs found matching "${args.query}"`
}
const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
+19 -1
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
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
bun run --cwd packages/desktop dev
```
To create a production `dist/` and build the native app bundle:
```bash
bun run --cwd packages/desktop tauri build
```
This runs `bun run --cwd packages/desktop build` automatically via Tauris `beforeBuildCommand`.
> [!NOTE]
> Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
+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
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS and Linux
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less frequently)
paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
@@ -70,8 +71,7 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
### Agents
OpenCode includes two built-in agents you can switch between,
you can switch between these using the `Tab` key.
OpenCode includes two built-in agents you can switch between with the `Tab` key.
- **build** - Default, full access agent for development work
- **plan** - Read-only agent for analysis and code exploration
@@ -108,10 +108,6 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
#### What's the other repo?
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
---
**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+116
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
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS 與 Linux
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
paru -S opencode-bin # Arch Linux
mise use -g github:anomalyco/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
+194 -189
View File
@@ -1,191 +1,196 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ------------------- | ------------------- | ------------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) |
| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) |
| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) |
| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) |
| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) |
| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) |
| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) |
| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) |
| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) |
| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) |
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | -------------------- | ------------------- | -------------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) |
| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) |
| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) |
| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) |
| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) |
| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) |
| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) |
| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) |
| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) |
| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) |
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) |
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
+2 -3
View File
@@ -1,9 +1,8 @@
## Style Guide
- Try to keep things in one function unless composable or reusable
- DO NOT do unnecessary destructuring of variables
- DO NOT use `else` statements unless necessary
- DO NOT use `try`/`catch` if it can be avoided
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
= obj` just reference it as obj.a and obj.b. this preserves context
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type
+43 -35
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -173,9 +173,10 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
@@ -201,7 +202,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -230,7 +231,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -246,7 +247,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.2",
"version": "1.1.6",
"bin": {
"opencode": "./bin/opencode",
},
@@ -276,7 +277,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.15.1",
"@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -285,8 +286,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.68",
"@opentui/solid": "0.1.68",
"@opentui/core": "0.1.69",
"@opentui/solid": "0.1.69",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -349,7 +350,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -369,7 +370,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.2",
"version": "1.1.6",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -380,7 +381,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -393,7 +394,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -401,6 +402,7 @@
"@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -431,7 +433,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"zod": "catalog:",
},
@@ -442,7 +444,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@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=="],
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
@@ -1093,7 +1097,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.15.1", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -1197,21 +1201,21 @@
"@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.69", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.69", "@opentui/core-darwin-x64": "0.1.69", "@opentui/core-linux-arm64": "0.1.69", "@opentui/core-linux-x64": "0.1.69", "@opentui/core-win32-arm64": "0.1.69", "@opentui/core-win32-x64": "0.1.69", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-BcEFnAuMq4vgfb+zxOP/l+NO1AS3fVHkYjn+E8Wpmaxr0AzWNTi2NPAMtQf+Wqufxo0NYh0gY4c9B6n8OxTjGw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.68", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ipPX2gavBLVtw3d8L4ZPJDLlEwIjIRNdlNlxu07rqSEGSfxD5s29yc+33wLAlYXbmnJDajOqm0Dx6HnlY1Y9Fg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.69", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d9RPAh84O2XIyMw+7+X0fEyi+4KH5sPk9AxLze8GHRBGOzkRunqagFCLBrN5VFs2e2nbhIYtjMszo7gcpWyh7g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.68", "", { "os": "darwin", "cpu": "x64" }, "sha512-9dW0S9HINnuVjvC9QLj+S+329H7qEBQQtyJ9WHpykemokiJ5k4rnuDkfws5FxgTHIf/ddoYYTyPoGCS7WN5gsQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.69", "", { "os": "darwin", "cpu": "x64" }, "sha512-41K9zkL2IG0ahL+8Gd+e9ulMrnJF6lArPzG7grjWzo+FWEZwvw0WLCO1/Gn5K85G8Yx7gQXkZOUaw1BmHjxoRw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.68", "", { "os": "linux", "cpu": "arm64" }, "sha512-/el6TbSQriBUfPhIa6SBfCCc7tjU98Bnhf2+w0zKwQFBjf3F3kmnI42++YxedMGFmL7bRt3EUawGOkQRZZzFAg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.69", "", { "os": "linux", "cpu": "arm64" }, "sha512-IcUjwjuIpX3BBG1a9kjMqWrHYCFHAVfjh5nIRozWZZoqaczLzJb3nJeF2eg8aDeIoGhXvERWB1r1gmqPW8u3vQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.68", "", { "os": "linux", "cpu": "x64" }, "sha512-9NzVI3GZzmICoIu3YhWBdkEt0KvY27m++tu/MqW+xb6fnvN74jZkRWzlgjTdM70obL4eUGQdvU08sDHgZjsIJw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.69", "", { "os": "linux", "cpu": "x64" }, "sha512-5S9vqEIq7q+MEdp4cT0HLegBWu0pWLcletHZL80bsLbJt9OT8en3sQmL5bvas9sIuyeBFru9bfCmrQ/gnVTTiA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.68", "", { "os": "win32", "cpu": "arm64" }, "sha512-wrAeotyotOplUjQVBSxOGA8GCr9FWXSd6xCEo1PEGo/NjuAOtvHmKoENzyFEP0GzFsjvoUOyy2dZb987oFAn9A=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.69", "", { "os": "win32", "cpu": "arm64" }, "sha512-eSKcGwbcnJJPtrTFJI7STZ7inSYeedHS0swwjZhh9SADAruEz08intamunOslffv5+mnlvRp7UBGK35cMjbv/w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.68", "", { "os": "win32", "cpu": "x64" }, "sha512-w0yBjvzs/oMIwVdWICL4XlUrfsPoVXd4+RDqiuu+Xi/zD0UgANSTRY2asXca+gPe5zPHLsxvz1bAG0Z7uGtmyw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.69", "", { "os": "win32", "cpu": "x64" }, "sha512-OjG/0jqYXURqbbUwNgSPrBA6yuKF3OOFh8JSG7VvzoYHJFJRmwVWY0fztWv/hgGHe354ti37c7JDJBQ44HOCdA=="],
"@opentui/solid": ["@opentui/solid@0.1.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.69", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.69", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-ls589N8P9gvcNW8uF+Il4xisF5Uouk0RRmSaLdzmItNJSW5J9Y0nPtMELta6hBp0yIRAurWUO1wtkKXVF+eaxg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -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=="],
"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=="],
@@ -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-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=="],
@@ -2781,7 +2787,9 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -3383,6 +3391,8 @@
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
@@ -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=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@@ -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/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="],
@@ -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=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
+1 -1
View File
@@ -82,7 +82,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1
+16
View File
@@ -76,6 +76,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
"checkout.session.completed",
"checkout.session.expired",
"charge.refunded",
"invoice.payment_succeeded",
"customer.created",
"customer.deleted",
"customer.updated",
@@ -97,6 +98,19 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
],
})
const zenProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Black",
})
const zenPrice = new stripe.Price("ZenBlackPrice", {
product: zenProduct.id,
unitAmount: 20000,
currency: "usd",
recurring: {
interval: "month",
intervalCount: 1,
},
})
const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"),
new sst.Secret("ZEN_MODELS2"),
@@ -106,6 +120,7 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS6"),
new sst.Secret("ZEN_MODELS7"),
]
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
@@ -147,6 +162,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK,
...ZEN_MODELS,
...($dev
? [
+145 -118
View File
@@ -16,16 +16,19 @@ Usage: install.sh [options]
Options:
-h, --help Display this help message
-v, --version <version> Install a specific version (e.g., 1.0.180)
-b, --binary <path> Install from a local binary instead of downloading
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
Examples:
curl -fsSL https://opencode.ai/install | bash
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
./install --binary /path/to/opencode
EOF
}
requested_version=${VERSION:-}
no_modify_path=false
binary_path=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do
exit 1
fi
;;
-b|--binary)
if [[ -n "${2:-}" ]]; then
binary_path="$2"
shift 2
else
echo -e "${RED}Error: --binary requires a path argument${NC}"
exit 1
fi
;;
--no-modify-path)
no_modify_path=true
shift
@@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do
esac
done
raw_os=$(uname -s)
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
case "$raw_os" in
Darwin*) os="darwin" ;;
Linux*) os="linux" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
esac
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
fi
if [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
if [ "$rosetta_flag" = "1" ]; then
arch="arm64"
fi
fi
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
exit 1
;;
esac
archive_ext=".zip"
if [ "$os" = "linux" ]; then
archive_ext=".tar.gz"
fi
is_musl=false
if [ "$os" = "linux" ]; then
if [ -f /etc/alpine-release ]; then
is_musl=true
fi
if command -v ldd >/dev/null 2>&1; then
if ldd --version 2>&1 | grep -qi musl; then
is_musl=true
fi
fi
fi
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
if [ "$os" = "darwin" ]; then
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
if [ "$avx2" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"
if [ "$needs_baseline" = "true" ]; then
target="$target-baseline"
fi
if [ "$is_musl" = "true" ]; then
target="$target-musl"
fi
filename="$APP-$target$archive_ext"
if [ "$os" = "linux" ]; then
if ! command -v tar >/dev/null 2>&1; then
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
exit 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
# If --binary is provided, skip all download/detection logic
if [ -n "$binary_path" ]; then
if [ ! -f "$binary_path" ]; then
echo -e "${RED}Error: Binary not found at ${binary_path}${NC}"
exit 1
fi
specific_version="local"
else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
# Verify the release exists before downloading
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
raw_os=$(uname -s)
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
case "$raw_os" in
Darwin*) os="darwin" ;;
Linux*) os="linux" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
esac
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
fi
if [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
if [ "$rosetta_flag" = "1" ]; then
arch="arm64"
fi
fi
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
exit 1
;;
esac
archive_ext=".zip"
if [ "$os" = "linux" ]; then
archive_ext=".tar.gz"
fi
is_musl=false
if [ "$os" = "linux" ]; then
if [ -f /etc/alpine-release ]; then
is_musl=true
fi
if command -v ldd >/dev/null 2>&1; then
if ldd --version 2>&1 | grep -qi musl; then
is_musl=true
fi
fi
fi
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
if [ "$os" = "darwin" ]; then
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
if [ "$avx2" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"
if [ "$needs_baseline" = "true" ]; then
target="$target-baseline"
fi
if [ "$is_musl" = "true" ]; then
target="$target-musl"
fi
filename="$APP-$target$archive_ext"
if [ "$os" = "linux" ]; then
if ! command -v tar >/dev/null 2>&1; then
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
exit 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
if [ -z "$requested_version" ]; then
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
exit 1
fi
else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
# Verify the release exists before downloading
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
exit 1
fi
fi
fi
@@ -187,11 +208,8 @@ check_version() {
if command -v opencode >/dev/null 2>&1; then
opencode_path=$(which opencode)
## TODO: check if version is installed
# installed_version=$(opencode version)
installed_version="0.0.1"
installed_version=$(echo $installed_version | awk '{print $2}')
## Check the installed version
installed_version=$(opencode --version 2>/dev/null || echo "")
if [[ "$installed_version" != "$specific_version" ]]; then
print_message info "${MUTED}Installed version: ${NC}$installed_version."
@@ -267,11 +285,11 @@ download_with_progress() {
{
local length=0
local bytes=0
while IFS=" " read -r -a line; do
[ "${#line[@]}" -lt 2 ] && continue
local tag="${line[0]} ${line[1]}"
if [ "$tag" = "0000: content-length:" ]; then
length="${line[2]}"
length=$(echo "$length" | tr -d '\r')
@@ -296,7 +314,7 @@ download_and_install() {
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
mkdir -p "$tmp_dir"
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
curl -# -L -o "$tmp_dir/$filename" "$url"
@@ -307,14 +325,24 @@ download_and_install() {
else
unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
fi
mv "$tmp_dir/opencode" "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode"
rm -rf "$tmp_dir"
}
check_version
download_and_install
install_from_binary() {
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path"
cp "$binary_path" "${INSTALL_DIR}/opencode"
chmod 755 "${INSTALL_DIR}/opencode"
}
if [ -n "$binary_path" ]; then
install_from_binary
else
check_version
download_and_install
fi
add_to_path() {
@@ -416,4 +444,3 @@ echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
echo -e ""
echo -e ""
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-OJ3C4RMzfbbG1Fwa/5yru0rlISj+28UPITMNBEU5AeM="
"nodeModules": "sha256-rNGq0yjL5ZHYVg+zyV4nFPug4gqhKhyOnfebaufyd34="
}
+6 -1
View File
@@ -60,7 +60,12 @@ const result = await Bun.build({
compile: {
target,
outfile: "opencode",
execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"],
autoloadBunfig: false,
autoloadDotenv: false,
//@ts-ignore (bun types aren't up to date)
autoloadTsconfig: true,
autoloadPackageJson: true,
execArgv: ["--user-agent=opencode/" + version, "--use-system-ca", "--"],
windows: {},
},
})
+2 -2
View File
@@ -1,8 +1,8 @@
## Usage
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
Dependencies for these templates are managed with [pnpm](https://pnpm.io) using `pnpm up -Lri`.
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
This is the reason you see a `pnpm-lock.yaml`. That said, any package manager will work. This file can safely be removed once you clone a template.
```bash
$ npm install # or pnpm install or yarn install
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.2",
"version": "1.1.6",
"description": "",
"type": "module",
"exports": {
+47
View File
@@ -242,6 +242,53 @@ describe("SerializeAddon", () => {
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
})
test("serialized output should restore after Terminal.reset()", async () => {
const { term, addon } = createTerminal()
const content = [
"\x1b[1;32m\x1b[0m \x1b[34mcd\x1b[0m /some/path",
"\x1b[1;32m\x1b[0m \x1b[34mls\x1b[0m -la",
"total 42",
].join("\r\n")
await writeAndWait(term, content)
const serialized = addon.serialize()
const { term: term2 } = createTerminal()
terminals.push(term2)
term2.reset()
await writeAndWait(term2, serialized)
expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
})
test("alternate buffer should round-trip without garbage", async () => {
const { term, addon } = createTerminal(20, 5)
await writeAndWait(term, "normal\r\n")
await writeAndWait(term, "\x1b[?1049h\x1b[HALT")
expect(term.buffer.active.type).toBe("alternate")
const serialized = addon.serialize()
const { term: term2 } = createTerminal(20, 5)
terminals.push(term2)
await writeAndWait(term2, serialized)
expect(term2.buffer.active.type).toBe("alternate")
const line = term2.buffer.active.getLine(0)
expect(line?.translateToString(true)).toBe("ALT")
// Ensure a cell beyond content isn't garbage
const cellCode = line?.getCell(10)?.getCode()
expect(cellCode === 0 || cellCode === 32).toBe(true)
})
test("serialized output written to new terminal should match original colors", async () => {
const { term, addon } = createTerminal(40, 5)
+19 -23
View File
@@ -157,23 +157,6 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
abstract class BaseSerializeHandler {
constructor(protected readonly _buffer: IBuffer) {}
private _isRealContent(codepoint: number): boolean {
if (codepoint === 0) return false
if (codepoint >= 0xf000) return false
return true
}
private _findLastContentColumn(line: IBufferLine): number {
let lastContent = -1
for (let col = 0; col < line.length; col++) {
const cell = line.getCell(col)
if (cell && this._isRealContent(cell.getCode())) {
lastContent = col
}
}
return lastContent + 1
}
public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
let oldCell = this._buffer.getNullCell()
@@ -182,14 +165,14 @@ abstract class BaseSerializeHandler {
const startColumn = range.start.x
const endColumn = range.end.x
this._beforeSerialize(endRow - startRow, startRow, endRow)
this._beforeSerialize(endRow - startRow + 1, startRow, endRow)
for (let row = startRow; row <= endRow; row++) {
const line = this._buffer.getLine(row)
if (line) {
const startLineColumn = row === range.start.y ? startColumn : 0
const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line)
const endLineColumn = Math.min(maxColumn, line.length)
const endLineColumn = Math.min(endColumn, line.length)
for (let col = startLineColumn; col < endLineColumn; col++) {
const c = line.getCell(col)
if (!c) {
@@ -243,6 +226,13 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _beforeSerialize(rows: number, start: number, _end: number): void {
this._allRows = new Array<string>(rows)
this._allRowSeparators = new Array<string>(rows)
this._rowIndex = 0
this._currentRow = ""
this._nullCellCount = 0
this._cursorStyle = this._buffer.getNullCell()
this._lastContentCursorRow = start
this._lastCursorRow = start
this._firstRow = start
@@ -251,6 +241,11 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = ""
if (this._nullCellCount > 0) {
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
if (!isLastRow) {
const nextLine = this._buffer.getLine(row + 1)
@@ -388,7 +383,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
const codepoint = cell.getCode()
const isGarbage = codepoint >= 0xf000
const isInvalidCodepoint = codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)
const isGarbage = isInvalidCodepoint || (codepoint >= 0xf000 && cell.getWidth() === 1)
const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
@@ -397,7 +393,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (styleChanged) {
if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C`
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
@@ -417,7 +413,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._nullCellCount += cell.getWidth()
} else {
if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C`
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
+77 -60
View File
@@ -1,5 +1,5 @@
import "@/index.css"
import { ErrorBoundary, Show, type ParentProps } from "solid-js"
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -20,16 +20,20 @@ import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { Logo } from "@opencode-ai/ui/logo"
import Layout from "@/pages/layout"
import Home from "@/pages/home"
import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { ErrorPage } from "./pages/error"
import { iife } from "@opencode-ai/util/iife"
import { Suspense } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full flex items-center justify-center text-text-weak">Loading...</div>
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
}
}
@@ -45,6 +49,25 @@ const defaultServerUrl = iife(() => {
return window.location.origin
})
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
)
}
function ServerKey(props: ParentProps) {
const server = useServer()
return (
@@ -54,62 +77,56 @@ function ServerKey(props: ParentProps) {
)
}
export function App() {
export function AppInterface() {
return (
<MetaProvider>
<Font />
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<ServerProvider defaultUrl={defaultServerUrl}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(props) => (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Session />
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ServerProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
<ServerProvider defaultUrl={defaultServerUrl}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(props) => (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)}
>
<Route
path="/"
component={() => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)}
/>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ServerProvider>
)
}
@@ -3,11 +3,12 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { Icon } from "@opencode-ai/ui/icon"
import { Avatar } from "@opencode-ai/ui/avatar"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { Avatar } from "@opencode-ai/ui/avatar"
import { ProjectIcon, isValidImageFile } from "@/components/project-icon"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
@@ -33,9 +34,11 @@ export function DialogEditProject(props: { project: LocalProject }) {
const [dragOver, setDragOver] = createSignal(false)
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
if (!isValidImageFile(file)) return
const reader = new FileReader()
reader.onload = (e) => setStore("iconUrl", e.target?.result as string)
reader.onload = (e) => {
setStore("iconUrl", e.target?.result as string)
}
reader.readAsDataURL(file)
}
@@ -98,7 +101,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
<div class="flex gap-3 items-start">
<div class="relative">
<div
class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
class="size-12 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
"border-border-base hover:border-border-strong": !dragOver(),
@@ -108,20 +111,13 @@ export function DialogEditProject(props: { project: LocalProject }) {
onDragLeave={handleDragLeave}
onClick={() => document.getElementById("icon-upload")?.click()}
>
<Show
when={store.iconUrl}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={store.name || defaultName()}
{...getAvatarColors(store.color)}
class="size-full"
/>
</div>
}
>
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
</Show>
<ProjectIcon
name={store.name || defaultName()}
projectId={props.project.id}
iconUrl={store.iconUrl}
iconColor={store.color}
class="size-full"
/>
</div>
<Show when={store.iconUrl}>
<button
@@ -27,6 +27,7 @@ export function DialogSelectFile() {
const value = file.tab(path)
tabs().open(value)
file.load(path)
layout.review.open()
}
dialog.close()
}}
@@ -0,0 +1,58 @@
import { createMemo, splitProps, type ComponentProps, type JSX } from "solid-js"
import { Avatar } from "@opencode-ai/ui/avatar"
import { getAvatarColors } from "@/context/layout"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
const OPENCODE_FAVICON_URL = "https://opencode.ai/favicon.svg"
export interface ProjectIconProps extends Omit<ComponentProps<"div">, "children"> {
name: string
iconUrl?: string
iconColor?: string
projectId?: string
size?: "small" | "normal" | "large"
}
export const isValidImageUrl = (url: string | undefined): boolean => {
if (!url) return false
if (url.startsWith("data:image/x-icon")) return false
if (url.startsWith("data:image/vnd.microsoft.icon")) return false
return true
}
export const isValidImageFile = (file: File): boolean => {
if (!file.type.startsWith("image/")) return false
if (file.type === "image/x-icon" || file.type === "image/vnd.microsoft.icon") return false
return true
}
export const ProjectIcon = (props: ProjectIconProps) => {
const [local, rest] = splitProps(props, [
"name",
"iconUrl",
"iconColor",
"projectId",
"size",
"class",
"classList",
"style",
])
const colors = createMemo(() => getAvatarColors(local.iconColor))
const validSrc = createMemo(() => {
if (local.projectId === OPENCODE_PROJECT_ID) return OPENCODE_FAVICON_URL
return isValidImageUrl(local.iconUrl) ? local.iconUrl : undefined
})
return (
<Avatar
fallback={local.name}
src={validSrc()}
size={local.size}
{...colors()}
class={local.class}
classList={local.classList}
style={local.style as JSX.CSSProperties}
{...rest}
/>
)
}
+68 -42
View File
@@ -1,5 +1,17 @@
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 { createFocusSignal } from "@solid-primitives/active-element"
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 { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission"
@@ -153,6 +165,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
const imageAttachments = createMemo(
() => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
)
const [store, setStore] = createStore<{
popover: "at" | "slash" | null
@@ -160,7 +175,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
savedPrompt: Prompt | null
placeholder: number
dragging: boolean
imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell"
applyingHistory: boolean
}>({
@@ -169,14 +183,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
savedPrompt: null,
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
dragging: false,
imageAttachments: [],
mode: "normal",
applyingHistory: false,
})
const MAX_HISTORY = 100
const [history, setHistory] = persisted(
"prompt-history.v1",
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{
entries: Prompt[]
}>({
@@ -184,7 +197,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
const [shellHistory, setShellHistory] = persisted(
"prompt-history-shell.v1",
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{
entries: Prompt[]
}>({
@@ -235,6 +248,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const isFocused = createFocusSignal(() => editorRef)
createEffect(() => {
params.id
editorRef.focus()
@@ -245,7 +260,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
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) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
@@ -260,21 +276,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mime: file.type,
dataUrl,
}
setStore(
produce((draft) => {
draft.imageAttachments.push(attachment)
}),
)
const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
prompt.set([...prompt.current(), attachment], cursorPosition)
}
reader.readAsDataURL(file)
}
const removeImageAttachment = (id: string) => {
setStore(
produce((draft) => {
draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
}),
)
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
prompt.set(next, prompt.cursor())
}
const handlePaste = async (event: ClipboardEvent) => {
@@ -282,12 +293,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const clipboardData = event.clipboardData
if (!clipboardData) return
event.preventDefault()
event.stopPropagation()
const items = Array.from(clipboardData.items)
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) {
event.preventDefault()
event.stopPropagation()
for (const item of imageItems) {
const file = item.getAsFile()
if (file) await addImageAttachment(file)
@@ -295,13 +307,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
event.preventDefault()
event.stopPropagation()
const plainText = clipboardData.getData("text/plain") ?? ""
addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
const handleGlobalDragOver = (event: DragEvent) => {
if (dialog.active) return
event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files")
if (hasFiles) {
@@ -310,6 +322,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleGlobalDragLeave = (event: DragEvent) => {
if (dialog.active) return
// relatedTarget is null when leaving the document window
if (!event.relatedTarget) {
setStore("dragging", false)
@@ -317,6 +331,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleGlobalDrop = async (event: DragEvent) => {
if (dialog.active) return
event.preventDefault()
setStore("dragging", false)
@@ -331,13 +347,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
onMount(() => {
editorRef.addEventListener("paste", handlePaste)
document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop)
})
onCleanup(() => {
editorRef.removeEventListener("paste", handlePaste)
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
@@ -518,8 +532,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
on(
() => prompt.current(),
(currentParts) => {
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
const selection = window.getSelection()
let cursorPosition: number | null = null
@@ -527,7 +542,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(currentParts)
renderEditor(inputParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
@@ -618,11 +633,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleInput = () => {
const rawParts = parseFromDOM()
const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef)
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const trimmed = rawText.replace(/\u200B/g, "").trim()
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) {
setStore("popover", null)
@@ -661,7 +677,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null)
}
prompt.set(rawParts, cursorPosition)
prompt.set([...rawParts, ...images], cursorPosition)
queueScroll()
}
@@ -764,16 +780,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.map((p) => ("content" in p ? p.content : ""))
.join("")
.trim()
if (!text) return
const hasImages = prompt.some((part) => part.type === "image")
if (!text && !hasImages) return
const entry = clonePromptParts(prompt)
const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const lastEntry = currentHistory.entries[0]
if (lastEntry) {
const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
if (lastText === text) return
}
if (lastEntry && isPromptEqual(lastEntry, entry)) return
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 === "at") {
atOnKeyDown(event)
@@ -943,7 +961,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const currentPrompt = prompt.current()
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = store.imageAttachments.slice()
const images = imageAttachments().slice()
const mode = store.mode
if (text.trim().length === 0 && images.length === 0) {
@@ -1037,14 +1055,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const clearInput = () => {
prompt.reset()
setStore("imageAttachments", [])
setStore("mode", "normal")
setStore("popover", null)
}
const restoreInput = () => {
prompt.set(currentPrompt, promptLength(currentPrompt))
setStore("imageAttachments", images)
setStore("mode", mode)
setStore("popover", null)
requestAnimationFrame(() => {
@@ -1087,6 +1103,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
})
.catch((err) => {
showToast({
@@ -1440,9 +1463,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</For>
</div>
</Show>
<Show when={store.imageAttachments.length > 0}>
<Show when={imageAttachments().length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={store.imageAttachments}>
<For each={imageAttachments()}>
{(attachment) => (
<div class="relative group">
<Show
@@ -1483,6 +1506,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
contenteditable="true"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
@@ -1492,7 +1518,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"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">
{store.mode === "shell"
? "Enter shell command..."
@@ -1566,14 +1592,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
@@ -1625,7 +1651,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<IconButton
type="submit"
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-6 w-4.5"
@@ -22,7 +22,7 @@ export function SessionLspIndicator() {
return (
<Show when={lspStats().total > 0}>
<Tooltip placement="top" value={tooltipContent()}>
<div class="flex items-center gap-1 px-2 cursor-default select-none">
<div class="pl-2.5 pr-4 flex gap-2 cursor-default select-none items-center justify-center">
<div
classList={{
"size-1.5 rounded-full": true,
@@ -1,41 +1,34 @@
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createMemo, Show } from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Select } from "@opencode-ai/ui/select"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { same } from "@/utils/same"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const command = useCommand()
const server = useServer()
const dialog = useDialog()
const sync = useSync()
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
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 worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
@@ -43,6 +36,8 @@ export function SessionHeader() {
function navigateToSession(session: Session | undefined) {
if (!session) return
// Only navigate if we're actually changing to a different session
if (session.id === params.id) return
navigate(`/${params.dir}/session/${session.id}`)
}
@@ -55,12 +50,12 @@ export function SessionHeader() {
>
<Icon name="menu" size="small" />
</button>
<div class="px-4 flex items-center justify-between gap-4 w-full">
<div class="px-4 flex items-center justify-between gap-3 w-full">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
options={worktrees()}
current={sync.project?.worktree ?? projectDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
@@ -77,130 +72,59 @@ export function SessionHeader() {
</Select>
<div class="text-text-weaker">/</div>
</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind>
</Show>
</div>
<div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
<Show
when={parentSession()}
fallback={
<>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
</>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
<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)
}
return shareURL
},
)
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
})}
</Popover>
}}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
<div class="text-text-weaker">/</div>
<div class="flex items-center gap-1.5 min-w-0">
<Tooltip value="Back to parent session">
<button
type="button"
class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0"
onClick={() => navigateToSession(parentSession())}
>
<Icon name="arrow-left" size="small" class="text-icon-base" />
</button>
</Tooltip>
</div>
</div>
</Show>
</div>
<Show when={currentSession() && !parentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="plus-small" variant="ghost" />
</TooltipKeybind>
</Show>
</div>
</div>
@@ -23,11 +23,18 @@ export function NewSessionView(props: NewSessionViewProps) {
if (options().includes(selection)) return selection
return MAIN_WORKTREE
})
const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
const isWorktree = createMemo(() => {
const project = sync.project
if (!project) return false
return sync.data.path.directory !== project.worktree
})
const label = (value: string) => {
if (value === MAIN_WORKTREE) {
if (isWorktree()) return "Main branch"
const branch = sync.data.vcs?.branch
if (branch) return `Current branch (${branch})`
if (branch) return `Main branch (${branch})`
return "Main branch"
}
@@ -37,13 +44,16 @@ export function NewSessionView(props: NewSessionViewProps) {
}
return (
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
<div
class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"
style={{ "padding-bottom": "calc(var(--prompt-height, 11.25rem) + 64px)" }}
>
<div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
{getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span>
</div>
</div>
<div class="flex justify-center items-center gap-1">
@@ -39,6 +39,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
</Tooltip>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
>
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</Tabs.Trigger>
+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 { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
@@ -16,6 +16,7 @@ type TerminalColors = {
background: string
foreground: string
cursor: string
selectionBackground: string
}
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
@@ -23,11 +24,13 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
background: "#fcfcfc",
foreground: "#211e1e",
cursor: "#211e1e",
selectionBackground: withAlpha("#211e1e", 0.2),
},
dark: {
background: "#191515",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
selectionBackground: withAlpha("#d4d4d4", 0.25),
},
}
@@ -36,12 +39,14 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket
let term: Term
let ws: WebSocket | undefined
let term: Term | undefined
let ghostty: Ghostty
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
let reconnect: number | undefined
let disposed = false
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
@@ -51,12 +56,16 @@ export const Terminal = (props: TerminalProps) => {
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-base"] ?? fallback.foreground
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha)
return {
background,
foreground: text,
cursor: text,
selectionBackground,
}
}
@@ -71,27 +80,11 @@ export const Terminal = (props: TerminalProps) => {
setOption("theme", colors)
})
const focusTerminal = () => term?.focus()
const copySelection = () => {
if (!term || !term.hasSelection()) return false
const selection = term.getSelection()
if (!selection) return false
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
if (!document.body) return false
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
document.body.removeChild(textarea)
return copied
const focusTerminal = () => {
const t = term
if (!t) return
t.focus()
setTimeout(() => t.textarea?.focus(), 0)
}
const handlePointerDown = () => {
const activeElement = document.activeElement
@@ -102,10 +95,15 @@ export const Terminal = (props: TerminalProps) => {
}
onMount(async () => {
ghostty = await Ghostty.load()
const mod = await import("ghostty-web")
ghostty = await mod.Ghostty.load()
ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
term = new Term({
const socket = new WebSocket(
sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
)
ws = socket
const t = new mod.Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "IBM Plex Mono, monospace",
@@ -114,50 +112,83 @@ export const Terminal = (props: TerminalProps) => {
scrollback: 10_000,
ghostty,
})
term.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (key === "c") {
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
if ((macCopy || linuxCopy) && copySelection()) {
event.preventDefault()
return true
}
term = t
const copy = () => {
const selection = t.getSelection()
if (!selection) return false
const body = document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
// allow for ctrl-` to toggle terminal in parent
if (event.ctrlKey && key === "`") {
event.preventDefault()
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
return false
}
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
copy()
return true
}
if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
if (!t.hasSelection()) return true
copy()
return true
}
// allow for ctrl-` to toggle terminal in parent
if (event.ctrlKey && key === "`") {
return true
}
return false
})
fitAddon = new FitAddon()
fitAddon = new mod.FitAddon()
serializeAddon = new SerializeAddon()
term.loadAddon(serializeAddon)
term.loadAddon(fitAddon)
t.loadAddon(serializeAddon)
t.loadAddon(fitAddon)
term.open(container)
t.open(container)
container.addEventListener("pointerdown", handlePointerDown)
focusTerminal()
if (local.pty.buffer) {
if (local.pty.rows && local.pty.cols) {
term.resize(local.pty.cols, local.pty.rows)
t.resize(local.pty.cols, local.pty.rows)
}
term.reset()
term.write(local.pty.buffer)
if (local.pty.scrollY) {
term.scrollToLine(local.pty.scrollY)
}
fitAddon.fit()
t.write(local.pty.buffer, () => {
if (local.pty.scrollY) {
t.scrollToLine(local.pty.scrollY)
}
fitAddon.fit()
})
}
fitAddon.observeResize()
handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize)
term.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) {
t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty
.update({
ptyID: local.pty.id,
@@ -169,39 +200,39 @@ export const Terminal = (props: TerminalProps) => {
.catch(() => {})
}
})
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data)
t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
})
term.onKey((key) => {
t.onKey((key) => {
if (key.key == "Enter") {
props.onSubmit?.()
}
})
// term.onScroll((ydisp) => {
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
ws.addEventListener("open", () => {
socket.addEventListener("open", () => {
console.log("WebSocket connected")
sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: term.cols,
rows: term.rows,
cols: t.cols,
rows: t.rows,
},
})
.catch(() => {})
})
ws.addEventListener("message", (event) => {
term.write(event.data)
socket.addEventListener("message", (event) => {
t.write(event.data)
})
ws.addEventListener("error", (error) => {
socket.addEventListener("error", (error) => {
console.error("WebSocket error:", error)
props.onConnectError?.(error)
})
ws.addEventListener("close", () => {
socket.addEventListener("close", () => {
console.log("WebSocket disconnected")
})
})
@@ -211,18 +242,21 @@ export const Terminal = (props: TerminalProps) => {
window.removeEventListener("resize", handleResize)
}
container.removeEventListener("pointerdown", handlePointerDown)
if (serializeAddon && props.onCleanup) {
const t = term
if (serializeAddon && props.onCleanup && t) {
const buffer = serializeAddon.serialize()
props.onCleanup({
...local.pty,
buffer,
rows: term.rows,
cols: term.cols,
scrollY: term.getViewportY(),
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),
})
}
ws?.close()
term?.dispose()
t?.dispose()
})
return (
@@ -0,0 +1,66 @@
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import type { Component, ComponentProps } from "solid-js"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { usePlatform } from "@/context/platform"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
export const TOOLBAR_PORTAL_ID = "toolbar-content-portal"
export const Toolbar: Component<ComponentProps<"div">> = ({ class: className, ...props }) => {
const command = useCommand()
const platform = usePlatform()
const layout = useLayout()
return (
<div
classList={{
"pl-[80px]": IS_MAC && platform.platform !== "web",
"pl-2": !IS_MAC || platform.platform === "web",
"py-2 min-h-[41px] mx-px bg-background-base border-b border-border-weak-base flex items-center justify-between w-full border-box relative": true,
...(className ? { [className]: true } : {}),
}}
data-tauri-drag-region
{...props}
>
<TooltipKeybind
class="shrink-0 relative z-10 xl:block hidden"
placement="bottom"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
size="normal"
class="group/sidebar-toggle shrink-0 text-left justify-center align-middle rounded-lg px-1.5"
onClick={layout.sidebar.toggle}
>
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
size="small"
class="group-hover/sidebar-toggle:hidden"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
size="small"
class="hidden group-hover/sidebar-toggle:inline-block"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
size="small"
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<div id={TOOLBAR_PORTAL_ID} class="contents" />
</div>
)
}
export { ToolbarSession } from "./session"
@@ -0,0 +1,147 @@
import { createMemo, createResource, Show, Component, type ComponentProps } from "solid-js"
import { useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { base64Decode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
export const ToolbarSession: Component<ComponentProps<"div">> = ({ class: className, ...props }) => {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
const server = useServer()
const dialog = useDialog()
const sync = useSync()
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
return (
<div class={`flex items-center gap-3 absolute right-5 top-1/2 -translate-y-1/2 ${className ?? ""}`} {...props}>
<div class="hidden md:flex items-center gap-1">
<Button
size="small"
variant="ghost"
class="flex gap-2 items-center justify-center"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon size="small" name="layout-bottom-partial" class="hidden group-hover/terminal-toggle:inline-block" />
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</div>
)
}
+12 -1
View File
@@ -177,8 +177,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const dialog = useDialog()
const options = createMemo(() => {
const all = registrations().flatMap((x) => x())
const seen = new Set<string>()
const all: CommandOption[] = []
for (const reg of registrations()) {
for (const opt of reg()) {
if (seen.has(opt.id)) continue
seen.add(opt.id)
all.push(opt)
}
}
const suggested = all.filter((x) => x.suggested && !x.disabled)
return [
...suggested.map((x) => ({
...x,
+33 -4
View File
@@ -1,4 +1,4 @@
import { createMemo, onCleanup } from "solid-js"
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2"
@@ -7,7 +7,7 @@ import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
export type FileSelection = {
startLine: number
@@ -134,10 +134,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {},
})
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const [view, setView, _, ready] = persisted(
viewKey(),
Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
createStore<{
file: Record<string, FileViewState>
}>({
@@ -145,6 +145,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}),
)
const MAX_VIEW_FILES = 500
const viewMeta = { pruned: false }
const pruneView = (keep?: string) => {
const keys = Object.keys(view.file)
if (keys.length <= MAX_VIEW_FILES) return
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
if (drop.length === 0) return
setView(
produce((draft) => {
for (const key of drop) {
delete draft.file[key]
}
}),
)
}
createEffect(() => {
if (!ready()) return
if (viewMeta.pruned) return
viewMeta.pruned = true
pruneView()
})
function ensure(path: string) {
if (!path) return
if (store.file[path]) return
@@ -233,6 +259,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scrollTop: top,
}
})
pruneView(path)
}
const setScrollLeft = (input: string, left: number) => {
@@ -244,6 +271,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scrollLeft: left,
}
})
pruneView(path)
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
@@ -256,6 +284,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
selectedLines: next,
}
})
pruneView(path)
}
onCleanup(() => stop())
+70 -5
View File
@@ -1,7 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { batch, onCleanup } from "solid-js"
import { usePlatform } from "./platform"
import { useServer } from "./server"
@@ -19,14 +19,79 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
let queue: Array<Queued | undefined> = []
const coalesced = new Map<string, number>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
const events = queue
queue = []
coalesced.clear()
if (events.length === 0) return
last = Date.now()
batch(() => {
for (const event of events) {
if (!event) continue
emitter.emit(event.directory, event.payload)
}
})
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
}
const stop = () => {
flush()
}
void (async () => {
const events = await eventSdk.global.event()
let yielded = Date.now()
for await (const event of events.stream) {
emitter.emit(event.directory ?? "global", event.payload)
}
})().catch(() => undefined)
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = undefined
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
onCleanup(() => abort.abort())
if (Date.now() - yielded < 8) continue
yielded = Date.now()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
})()
.finally(stop)
.catch(() => undefined)
onCleanup(() => {
abort.abort()
stop()
})
const platform = usePlatform()
const sdk = createOpencodeClient({
+3 -2
View File
@@ -23,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error"
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
@@ -212,7 +212,7 @@ function createGlobalSync() {
.catch((e) => setGlobalStore("error", e))
}
globalSDK.event.listen((e) => {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -404,6 +404,7 @@ function createGlobalSync() {
}
}
})
onCleanup(unsub)
async function bootstrap() {
const health = await globalSDK.client.global
@@ -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 { batch, createMemo, onMount } from "solid-js"
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { Project } from "@opencode-ai/sdk/v2"
import { persisted } from "@/utils/persist"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -23,23 +25,11 @@ export function getAvatarColors(key?: string) {
}
}
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type SessionTabs = {
active?: string
all: string[]
}
type SessionScroll = {
x: number
y: number
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
@@ -56,7 +46,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync()
const server = useServer()
const [store, setStore, _, ready] = persisted(
"layout.v6",
Persist.global("layout", ["layout.v6"]),
createStore({
sidebar: {
opened: false,
@@ -81,6 +71,121 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}),
)
const MAX_SESSION_KEYS = 50
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
const SESSION_STATE_KEYS = [
{ key: "prompt", legacy: "prompt", version: "v2" },
{ key: "terminal", legacy: "terminal", version: "v1" },
{ key: "file-view", legacy: "file", version: "v1" },
] as const
const dropSessionState = (keys: string[]) => {
for (const key of keys) {
const parts = key.split("/")
const dir = parts[0]
const session = parts[1]
if (!dir) continue
for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
void removePersisted(target)
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey })
}
}
}
function prune(keep?: string) {
if (!keep) return
const keys = new Set<string>()
for (const key of Object.keys(store.sessionView)) keys.add(key)
for (const key of Object.keys(store.sessionTabs)) keys.add(key)
if (keys.size <= MAX_SESSION_KEYS) return
const score = (key: string) => {
if (key === keep) return Number.MAX_SAFE_INTEGER
return used.get(key) ?? 0
}
const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
const drop = ordered.slice(MAX_SESSION_KEYS)
if (drop.length === 0) return
setStore(
produce((draft) => {
for (const key of drop) {
delete draft.sessionView[key]
delete draft.sessionTabs[key]
}
}),
)
scroll.drop(drop)
dropSessionState(drop)
for (const key of drop) {
used.delete(key)
}
}
function touch(sessionKey: string) {
meta.active = sessionKey
used.set(sessionKey, Date.now())
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
prune(sessionKey)
}
const scroll = createScrollPersistence({
debounceMs: 250,
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
onFlush: (sessionKey, next) => {
const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
prune(keep)
return
}
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
prune(keep)
},
})
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
const active = meta.active
if (!active) return
meta.pruned = true
prune(active)
})
onMount(() => {
const flush = () => batch(() => scroll.flushAll())
const handleVisibility = () => {
if (document.visibilityState !== "hidden") return
flush()
}
window.addEventListener("pagehide", flush)
document.addEventListener("visibilitychange", handleVisibility)
onCleanup(() => {
window.removeEventListener("pagehide", flush)
document.removeEventListener("visibilitychange", handleVisibility)
scroll.dispose()
})
})
const usedColors = new Set<AvatarColorKey>()
function pickAvailableColor(): AvatarColorKey {
@@ -97,8 +202,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
: globalSync.data.project.find((x) => x.worktree === project.worktree)
return [
{
...project,
...(metadata ?? {}),
...project,
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
},
]
@@ -115,6 +220,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return project
}
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of globalSync.data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
}
}
return map
})
createEffect(() => {
const map = roots()
if (map.size === 0) return
const projects = server.projects.list()
const seen = new Set(projects.map((project) => project.worktree))
batch(() => {
for (const project of projects) {
const root = map.get(project.worktree)
if (!root) continue
server.projects.close(project.worktree)
if (!seen.has(root)) {
server.projects.open(root)
seen.add(root)
}
if (project.expanded) server.projects.expand(root)
}
})
})
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize))
@@ -131,11 +271,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: {
list,
open(directory: string) {
if (server.projects.list().find((x) => x.worktree === directory)) {
return
}
globalSync.project.loadSessions(directory)
server.projects.open(directory)
const root = roots().get(directory) ?? directory
if (server.projects.list().find((x) => x.worktree === root)) return
globalSync.project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {
server.projects.close(directory)
@@ -225,21 +364,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
view(sessionKey: string) {
touch(sessionKey)
scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
return {
scroll(tab: string) {
return s().scroll?.[tab]
return scroll.scroll(sessionKey, tab)
},
setScroll(tab: string, pos: SessionScroll) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
return
}
const prev = current.scroll?.[tab]
if (prev?.x === pos.x && prev?.y === pos.y) return
setStore("sessionView", sessionKey, "scroll", tab, pos)
scroll.setScroll(sessionKey, tab, pos)
},
review: {
open: createMemo(() => s().reviewOpen),
@@ -257,6 +390,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
tabs(sessionKey: string) {
touch(sessionKey)
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return {
tabs,
+5 -4
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createMemo } from "solid-js"
import { batch, createMemo, onCleanup } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -8,7 +8,7 @@ import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@opencode-ai/ui/toast"
export type LocalFile = FileNode &
@@ -111,7 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const model = (() => {
const [store, setStore, _, modelReady] = persisted(
"model.v1",
Persist.global("model", ["model.v1"]),
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
@@ -465,7 +465,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const searchFilesAndDirectories = (query: string) =>
sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
sdk.event.listen((e) => {
const unsub = sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "file.watcher.updated":
@@ -475,6 +475,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
break
}
})
onCleanup(unsub)
return {
node: async (path: string) => {
+30 -5
View File
@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store"
import { createEffect, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync"
@@ -9,7 +10,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
type NotificationBase = {
directory?: string
@@ -30,6 +31,16 @@ type ErrorNotification = NotificationBase & {
export type Notification = TurnCompleteNotification | ErrorNotification
const MAX_NOTIFICATIONS = 500
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
function pruneNotifications(list: Notification[]) {
const cutoff = Date.now() - NOTIFICATION_TTL_MS
const pruned = list.filter((n) => n.time >= cutoff)
if (pruned.length <= MAX_NOTIFICATIONS) return pruned
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
}
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
@@ -48,13 +59,26 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
"notification.v1",
Persist.global("notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
globalSDK.event.listen((e) => {
const meta = { pruned: false }
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
setStore("list", pruneNotifications(store.list))
})
const append = (notification: Notification) => {
setStore("list", (list) => pruneNotifications([...list, notification]))
}
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
const base = {
@@ -72,7 +96,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
try {
idlePlayer?.play()
} catch {}
setStore("list", store.list.length, {
append({
...base,
type: "turn-complete",
session: sessionID,
@@ -91,7 +115,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
errorPlayer?.play()
} catch {}
const error = "error" in event.properties ? event.properties.error : undefined
setStore("list", store.list.length, {
append({
...base,
type: "error",
session: sessionID ?? "global",
@@ -104,6 +128,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
}
}
})
onCleanup(unsub)
return {
ready,
+65 -20
View File
@@ -1,12 +1,12 @@
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
import { base64Decode } from "@opencode-ai/util/encode"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
type PermissionRespondFn = (input: {
sessionID: string
@@ -19,6 +19,32 @@ function shouldAutoAccept(perm: PermissionRequest) {
return perm.permission === "edit"
}
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
if (typeof rule !== "object") return false
if (Array.isArray(rule)) return false
for (const action of Object.values(rule)) {
if (action !== "allow") return true
}
return false
}
function hasAutoAcceptPermissionConfig(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
if (Array.isArray(permission)) return false
const config = permission as Record<string, unknown>
if (isNonAllowRule(config.edit)) return true
if (isNonAllowRule(config.write)) return true
return false
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
init: () => {
@@ -27,13 +53,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => {
if (!params.dir || !base64Decode(params.dir)) return false
const [store] = globalSync.child(base64Decode(params.dir))
return store.config.permission !== undefined
const directory = params.dir ? base64Decode(params.dir) : undefined
if (!directory) return false
const [store] = globalSync.child(directory)
return hasAutoAcceptPermissionConfig(store.config.permission)
})
const [store, setStore, _, ready] = persisted(
"permission.v3",
Persist.global("permission", ["permission.v3"]),
createStore({
autoAcceptEdits: {} as Record<string, boolean>,
}),
@@ -58,8 +85,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
}
function isAutoAccepting(sessionID: string) {
return store.autoAcceptEdits[sessionID] ?? false
function acceptKey(sessionID: string, directory?: string) {
if (!directory) return sessionID
return `${base64Encode(directory)}/${sessionID}`
}
function isAutoAccepting(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
}
const unsubscribe = globalSDK.event.listen((e) => {
@@ -67,7 +100,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (event?.type !== "permission.asked") return
const perm = event.properties
if (!isAutoAccepting(perm.sessionID)) return
if (!isAutoAccepting(perm.sessionID, e.name)) return
if (!shouldAutoAccept(perm)) return
respondOnce(perm, e.name)
@@ -75,7 +108,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
onCleanup(unsubscribe)
function enable(sessionID: string, directory: string) {
setStore("autoAcceptEdits", sessionID, true)
const key = acceptKey(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAcceptEdits[key] = true
delete draft.autoAcceptEdits[sessionID]
}),
)
globalSDK.client.permission
.list({ directory })
@@ -90,31 +129,37 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
.catch(() => undefined)
}
function disable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, false)
function disable(sessionID: string, directory?: string) {
const key = directory ? acceptKey(sessionID, directory) : undefined
setStore(
produce((draft) => {
if (key) delete draft.autoAcceptEdits[key]
delete draft.autoAcceptEdits[sessionID]
}),
)
}
return {
ready,
respond,
autoResponds(permission: PermissionRequest) {
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
autoResponds(permission: PermissionRequest, directory?: string) {
return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
},
isAutoAccepting,
toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID)) {
disable(sessionID)
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID)) return
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string) {
disable(sessionID)
disableAutoAccept(sessionID: string, directory?: string) {
disable(sessionID, directory)
},
permissionsEnabled,
}
+3 -3
View File
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
interface PartBase {
content: string
@@ -103,10 +103,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
name: "Prompt",
init: () => {
const params = useParams()
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
const [store, setStore, _, ready] = persisted(
name(),
Persist.scoped(params.dir!, params.id, "prompt", [legacy()]),
createStore<{
prompt: Prompt
cursor?: number
+3 -1
View File
@@ -1,6 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
@@ -20,9 +21,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
globalSDK.event.on(props.directory, async (event) => {
const unsub = globalSDK.event.on(props.directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
},
+43 -21
View File
@@ -1,9 +1,9 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
type StoredProject = { worktree: string; expanded: boolean }
@@ -35,7 +35,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
"server.v3",
Persist.global("server", ["server.v3"]),
createStore({
list: [] as string[],
projects: {} as Record<string, StoredProject[]>,
@@ -91,27 +91,49 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!active())
const [healthy, { refetch }] = createResource(
() => active() || undefined,
async (url) => {
if (!url) return
const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
.then((x) => x.data?.healthy === true)
.catch(() => false)
},
)
const check = (url: string) => {
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
.then((x) => x.data?.healthy === true)
.catch(() => false)
}
createEffect(() => {
if (!active()) return
const interval = setInterval(() => refetch(), 10_000)
onCleanup(() => clearInterval(interval))
const url = active()
if (!url) return
setHealthy(undefined)
let alive = true
let busy = false
const run = () => {
if (busy) return
busy = true
void check(url)
.then((next) => {
if (!alive) return
setHealthy(next)
})
.finally(() => {
busy = false
})
}
run()
const interval = setInterval(run, 10_000)
onCleanup(() => {
alive = false
clearInterval(interval)
})
})
const origin = createMemo(() => projectsKey(active()))
+3 -3
View File
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import { persisted } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
export type LocalPTY = {
id: string
@@ -19,10 +19,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
init: () => {
const sdk = useSDK()
const params = useParams()
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
const [store, setStore, _, ready] = persisted(
name(),
Persist.scoped(params.dir!, params.id, "terminal", [legacy()]),
createStore<{
active?: string
all: LocalPTY[]
+4 -2
View File
@@ -1,6 +1,6 @@
// @refresh reload
import { render } from "solid-js/web"
import { App } from "@/app"
import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import pkg from "../package.json"
@@ -55,7 +55,9 @@ const platform: Platform = {
render(
() => (
<PlatformProvider value={platform}>
<App />
<AppBaseProviders>
<AppInterface />
</AppBaseProviders>
</PlatformProvider>
),
root!,
+1 -1
View File
@@ -1,2 +1,2 @@
export { PlatformProvider, type Platform } from "./context/platform"
export { App } from "./app"
export { AppBaseProviders, AppInterface } from "./app"
+36 -5
View File
@@ -1,8 +1,10 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
import { createMemo, createSignal, onMount, Show, type ParentProps } from "solid-js"
import { Portal } from "solid-js/web"
import { useNavigate, useParams } from "@solidjs/router"
import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { ToolbarSession, TOOLBAR_PORTAL_ID } from "@/components/toolbar"
import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context"
@@ -10,9 +12,11 @@ import { iife } from "@opencode-ai/util/iife"
export default function Layout(props: ParentProps) {
const params = useParams()
const navigate = useNavigate()
const directory = createMemo(() => {
return base64Decode(params.dir!)
})
return (
<Show when={params.dir} keyed>
<SDKProvider directory={directory()}>
@@ -26,10 +30,37 @@ export default function Layout(props: ParentProps) {
response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input)
const navigateToSession = (sessionID: string) => {
navigate(`/${params.dir}/session/${sessionID}`)
}
const [portalMount, setPortalMount] = createSignal<HTMLElement | null>(null)
onMount(() => {
setPortalMount(document.getElementById(TOOLBAR_PORTAL_ID))
})
const toolbarKey = createMemo(() => params.id ?? "new")
return (
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
<>
<Show when={portalMount()}>
{(mount) => (
<Portal mount={mount()}>
<Show when={toolbarKey()} keyed>
<ToolbarSession />
</Show>
</Portal>
)}
</Show>
<DataProvider
data={sync.data}
directory={directory()}
onPermissionRespond={respond}
onNavigateToSession={navigateToSession}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
</>
)
})}
</SyncProvider>
+177 -220
View File
@@ -6,32 +6,31 @@ import {
Match,
onCleanup,
onMount,
ParentProps,
Show,
Switch,
untrack,
type JSX,
type ParentProps,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Mark } from "@opencode-ai/ui/logo"
import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Mark } from "@opencode-ai/ui/logo"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { createStore, produce } from "solid-js/store"
import {
DragDropProvider,
DragDropSensors,
@@ -57,6 +56,8 @@ import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { useServer } from "@/context/server"
import { Toolbar } from "@/components/toolbar"
import { ProjectIcon } from "@/components/project-icon"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -170,7 +171,7 @@ export default function Layout(props: ParentProps) {
if (e.details?.type !== "permission.asked") return
const directory = e.name
const perm = e.details.properties
if (permission.autoResponds(perm)) return
if (permission.autoResponds(perm, directory)) return
const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === perm.sessionID)
@@ -266,24 +267,30 @@ export default function Layout(props: ParentProps) {
}
}
function projectSessions(directory: string) {
if (!directory) return []
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
return (sessions ?? []).filter((s) => !s.parentID)
const currentProject = createMemo(() => {
const directory = params.dir ? base64Decode(params.dir) : undefined
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
function projectSessions(project: LocalProject | undefined) {
if (!project) return []
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const stores = dirs.map((dir) => globalSync.child(dir)[0])
const sessions = stores
.flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
.toSorted(sortSessions)
return sessions.filter((s) => !s.parentID)
}
const currentSessions = createMemo(() => {
if (!params.dir) return []
const directory = base64Decode(params.dir)
return projectSessions(directory)
})
const currentSessions = createMemo(() => projectSessions(currentProject()))
function navigateSessionByOffset(offset: number) {
const projects = layout.projects.list()
if (projects.length === 0) return
const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
const project = currentProject()
const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1
if (projectIndex === -1) {
const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
@@ -312,14 +319,14 @@ export default function Layout(props: ParentProps) {
const nextProject = projects[nextProjectIndex]
if (!nextProject) return
const nextProjectSessions = projectSessions(nextProject.worktree)
const nextProjectSessions = projectSessions(nextProject)
if (nextProjectSessions.length === 0) {
navigateToProject(nextProject.worktree)
return
}
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
navigateToSession(targetSession)
queueMicrotask(() => scrollToSession(targetSession.id))
}
@@ -465,7 +472,7 @@ export default function Layout(props: ParentProps) {
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session?.id}`)
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
layout.mobileSidebar.hide()
}
@@ -514,7 +521,8 @@ export default function Layout(props: ParentProps) {
const id = params.id
setStore("lastSession", directory, id)
notification.session.markViewed(id)
untrack(() => layout.projects.expand(directory))
const project = currentProject()
untrack(() => layout.projects.expand(project?.worktree ?? directory))
requestAnimationFrame(() => scrollToSession(id))
})
@@ -568,14 +576,15 @@ export default function Layout(props: ParentProps) {
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
<div class="relative size-5 shrink-0 rounded-sm">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
{...getAvatarColors(props.project.icon?.color)}
<ProjectIcon
name={name()}
projectId={props.project.id}
iconUrl={props.project.icon?.url}
iconColor={props.project.icon?.color}
size="small"
class={`size-full ${props.class ?? ""}`}
style={
notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
@@ -644,13 +653,13 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const [sessionStore] = globalSync.child(props.session.directory)
const hasPermissions = createMemo(() => {
const store = globalSync.child(props.project.worktree)[0]
const permissions = store.permission?.[props.session.id] ?? []
const permissions = sessionStore.permission?.[props.session.id] ?? []
if (permissions.length > 0) return true
const childSessions = store.session.filter((s) => s.parentID === props.session.id)
const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id)
for (const child of childSessions) {
const childPermissions = store.permission?.[child.id] ?? []
const childPermissions = sessionStore.permission?.[child.id] ?? []
if (childPermissions.length > 0) return true
}
return false
@@ -658,81 +667,79 @@ export default function Layout(props: ParentProps) {
const isWorking = createMemo(() => {
if (props.session.id === params.id) return false
if (hasPermissions()) return false
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
const status = sessionStore.session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry"
})
return (
<>
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
<A
href={`${props.slug}/session/${props.session.id}`}
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
>
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
<span
classList={{
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
"animate-pulse": isWorking(),
}}
>
{props.session.title}
</span>
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<Switch>
<Match when={isWorking()}>
<Spinner class="size-2.5 mr-0.5" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
</Match>
<Match when={true}>
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
{Math.abs(updated().diffNow().as("seconds")) < 60
? "Now"
: updated()
.toRelative({
style: "short",
unit: ["days", "hours", "minutes"],
})
?.replace(" ago", "")
?.replace(/ days?/, "d")
?.replace(" min.", "m")
?.replace(" hr.", "h")}
</span>
</Match>
</Switch>
</div>
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
<A
href={`${props.slug}/session/${props.session.id}`}
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
>
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
<span
classList={{
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
"animate-pulse": isWorking(),
}}
>
{props.session.title}
</span>
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<Switch>
<Match when={isWorking()}>
<Spinner class="size-2.5 mr-0.5" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
</Match>
<Match when={true}>
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
{Math.abs(updated().diffNow().as("seconds")) < 60
? "Now"
: updated()
.toRelative({
style: "short",
unit: ["days", "hours", "minutes"],
})
?.replace(" ago", "")
?.replace(/ days?/, "d")
?.replace(" min.", "m")
?.replace(" hr.", "h")}
</span>
</Match>
</Switch>
</div>
<Show when={props.session.summary?.files}>
<div class="flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span>
<Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
</div>
</Show>
</A>
</Tooltip>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
<TooltipKeybind
placement={props.mobile ? "bottom" : "right"}
title="Archive session"
keybind={command.keybind("session.archive")}
>
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</TooltipKeybind>
</div>
</div>
<Show when={props.session.summary?.files}>
<div class="flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span>
<Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
</div>
</Show>
</A>
</Tooltip>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
<TooltipKeybind
placement={props.mobile ? "bottom" : "right"}
title="Archive session"
keybind={command.keybind("session.archive")}
>
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</TooltipKeybind>
</div>
</>
</div>
)
}
@@ -759,6 +766,10 @@ export default function Layout(props: ParentProps) {
const isExpanded = createMemo(() =>
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
)
const isActive = createMemo(() => {
const current = params.dir ? base64Decode(params.dir) : ""
return props.project.worktree === current || props.project.sandboxes?.includes(current)
})
const handleOpenChange = (open: boolean) => {
if (props.mobile) {
if (open) mobileProjects.expand(props.project.worktree)
@@ -769,7 +780,7 @@ export default function Layout(props: ParentProps) {
}
}
return (
// @ts-ignore
// @ts-expect-error - SolidJS directive
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Switch>
<Match when={showExpanded()}>
@@ -777,7 +788,10 @@ export default function Layout(props: ParentProps) {
<Button
as={"div"}
variant="ghost"
class="group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg"
classList={{
"group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true,
"bg-surface-raised-base-hover": isActive() && !isExpanded(),
}}
>
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
<ProjectAvatar
@@ -888,58 +902,7 @@ export default function Layout(props: ParentProps) {
return (
<div class="flex flex-col self-stretch h-full items-center justify-between overflow-hidden min-h-0">
<div class="flex flex-col items-start self-stretch gap-4 min-h-0">
<Show when={!sidebarProps.mobile}>
<div
classList={{
"border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0": true,
"justify-start": expanded(),
}}
>
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2 w-full" data-tauri-drag-region>
<Mark class="shrink-0" />
</A>
</div>
</Show>
<div class="flex flex-col items-start self-stretch gap-4 px-2 overflow-hidden min-h-0">
<Show when={!sidebarProps.mobile}>
<TooltipKeybind
class="shrink-0"
placement="right"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
inactive={expanded()}
>
<Button
variant="ghost"
size="large"
class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
onClick={layout.sidebar.toggle}
>
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
size="small"
class="group-hover/sidebar-toggle:hidden"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
size="small"
class="hidden group-hover/sidebar-toggle:inline-block"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
size="small"
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
<Show when={layout.sidebar.opened()}>
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
Toggle sidebar
</div>
</Show>
</Button>
</TooltipKeybind>
</Show>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
@@ -1042,71 +1005,65 @@ export default function Layout(props: ParentProps) {
}
return (
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<div class="flex-1 min-h-0 flex">
<div
classList={{
"hidden xl:block": true,
"relative shrink-0": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
>
<>
<Toolbar />
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<div class="flex-1 min-h-0 flex">
<div
classList={{
"@container w-full h-full pb-5 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
"hidden xl:block": true,
"relative shrink-0": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
>
<SidebarContent />
</div>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={150}
max={window.innerWidth * 0.3}
collapseThreshold={80}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
/>
</Show>
</div>
<div class="xl:hidden">
<div
classList={{
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<div
classList={{
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pb-5 transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
<div class="border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0">
<A
href="/"
class="shrink-0 h-8 flex items-center justify-start px-2 w-full"
onClick={() => layout.mobileSidebar.hide()}
>
<Mark class="shrink-0" />
</A>
<div
classList={{
"@container w-full h-full py-3 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
}}
>
<SidebarContent />
</div>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={150}
max={window.innerWidth * 0.3}
collapseThreshold={80}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
/>
</Show>
</div>
<div class="xl:hidden">
<div
classList={{
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onPointerDown={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<div
classList={{
"@container fixed inset-y-0 left-0 top-[41px] z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between py-5 transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onPointerDown={(e) => e.stopPropagation()}
>
<SidebarContent mobile />
</div>
<SidebarContent mobile />
</div>
</div>
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
</div>
<Toast.Region />
</div>
<Toast.Region />
</div>
</>
)
}
+88 -22
View File
@@ -1,5 +1,6 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, batch } from "solid-js"
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file"
@@ -24,7 +25,7 @@ import { useSync } from "@/context/sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal"
import { checksum, base64Decode } from "@opencode-ai/util/encode"
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
@@ -48,12 +49,7 @@ import {
NewSessionView,
} from "@/components/session"
import { usePlatform } from "@/context/platform"
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
import { same } from "@/utils/same"
type DiffStyle = "unified" | "split"
@@ -62,6 +58,7 @@ interface SessionReviewTabProps {
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void
classes?: {
root?: string
header?: string
@@ -141,6 +138,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
diffs={props.diffs()}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
onViewFile={props.onViewFile}
/>
)
}
@@ -253,6 +251,14 @@ export default function Page() {
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "review",
newSessionWorktree: "main",
promptHeight: 0,
})
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
const project = sync.project
if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
return "main"
})
const activeMessage = createMemo(() => {
@@ -287,6 +293,8 @@ export default function Page() {
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let scroller: HTMLDivElement | undefined
createEffect(() => {
if (!params.id) return
@@ -459,7 +467,10 @@ export default function Page() {
},
{
id: "permissions.autoaccept",
title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? "Stop auto-accepting edits"
: "Auto-accept edits",
category: "Permissions",
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
@@ -468,8 +479,10 @@ export default function Page() {
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
description: permission.isAutoAccepting(sessionID)
title: permission.isAutoAccepting(sessionID, sdk.directory)
? "Auto-accepting edits"
: "Stopped auto-accepting edits",
description: permission.isAutoAccepting(sessionID, sdk.directory)
? "Edit and write permissions will be automatically approved"
: "Edit and write permissions will require approval",
})
@@ -496,7 +509,7 @@ export default function Page() {
// Restore the prompt from the reverted message
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts)
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
// Navigate to the message before the reverted one (which will be the new last visible message)
@@ -666,9 +679,30 @@ export default function Page() {
const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el)
}
createResizeObserver(
() => promptDock,
({ height }) => {
const next = Math.ceil(height)
if (next === store.promptHeight) return
const el = scroller
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
setStore("promptHeight", next)
if (stick && el) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
})
}
},
)
const updateHash = (id: string) => {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
@@ -774,7 +808,10 @@ export default function Page() {
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 md:flex-none py-6 md:py-3": true,
}}
style={{ width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%" }}
style={{
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
}}
>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
@@ -788,8 +825,13 @@ export default function Page() {
diffs={diffs}
view={view}
diffStyle="unified"
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
classes={{
root: "pb-32",
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4",
container: "px-4",
}}
@@ -815,12 +857,12 @@ export default function Page() {
autoScroll.handleScroll()
if (isDesktop()) scheduleScrollSpy(e.currentTarget)
}}
onClick={autoScroll.handleInteraction}
onPointerDown={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
>
<div
ref={autoScroll.contentRef}
class="flex flex-col gap-32 items-start justify-start pb-32 md:pb-40 transition-[margin]"
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
classList={{
"mt-0.5": !showTabs(),
"mt-0": showTabs(),
@@ -833,9 +875,9 @@ export default function Page() {
data-message-id={message.id}
classList={{
"min-w-0 w-full max-w-full": true,
"last:min-h-[calc(100vh-13.5rem)] md:last:min-h-[calc(100vh-14.5rem)]":
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
platform.platform !== "desktop",
"last:min-h-[calc(100vh-15rem)] md:last:min-h-[calc(100vh-16rem)]":
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
platform.platform === "desktop",
}}
>
@@ -871,15 +913,33 @@ export default function Page() {
</Match>
<Match when={true}>
<NewSessionView
worktree={store.newSessionWorktree}
onWorktreeChange={(value) => setStore("newSessionWorktree", value)}
worktree={newSessionWorktree()}
onWorktreeChange={(value) => {
if (value === "create") {
setStore("newSessionWorktree", value)
return
}
setStore("newSessionWorktree", "main")
const target = value === "main" ? sync.project?.worktree : value
if (!target) return
if (target === sync.data.path.directory) return
layout.projects.open(target)
navigate(`/${base64Encode(target)}/session`)
}}
/>
</Match>
</Switch>
</div>
{/* Prompt input */}
<div class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none">
<div
ref={(el) => {
promptDock = el
}}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
>
<div
classList={{
"w-full md:px-6 pointer-events-auto": true,
@@ -890,7 +950,7 @@ export default function Page() {
ref={(el) => {
inputRef = el
}}
newSessionWorktree={store.newSessionWorktree}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
/>
</div>
@@ -947,6 +1007,7 @@ export default function Page() {
</Tooltip>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
@@ -981,6 +1042,11 @@ export default function Page() {
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/>
</div>
</Tabs.Content>
+224 -6
View File
@@ -1,17 +1,235 @@
import { usePlatform } from "@/context/platform"
import { makePersisted } from "@solid-primitives/storage"
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
const platform = usePlatform()
const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
type PersistTarget = {
storage?: string
key: string
legacy?: string[]
migrate?: (value: unknown) => unknown
}
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
function snapshot(value: unknown) {
return JSON.parse(JSON.stringify(value)) as unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function merge(defaults: unknown, value: unknown): unknown {
if (value === undefined) return defaults
if (value === null) return value
if (Array.isArray(defaults)) {
if (Array.isArray(value)) return value
return defaults
}
if (isRecord(defaults)) {
if (!isRecord(value)) return defaults
const result: Record<string, unknown> = { ...defaults }
for (const key of Object.keys(value)) {
if (key in defaults) {
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
} else {
result[key] = (value as Record<string, unknown>)[key]
}
}
return result
}
return value
}
function parse(value: string) {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
function workspaceStorage(dir: string) {
const head = dir.slice(0, 12) || "workspace"
const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat`
}
function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:`
return {
getItem: (key) => localStorage.getItem(base + key),
setItem: (key, value) => localStorage.setItem(base + key, value),
removeItem: (key) => localStorage.removeItem(base + key),
}
}
export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
},
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
},
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
},
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
if (session) return Persist.session(dir, session, key, legacy)
return Persist.workspace(dir, key, legacy)
},
}
export function removePersisted(target: { storage?: string; key: string }) {
const platform = usePlatform()
const isDesktop = platform.platform === "desktop" && !!platform.storage
if (isDesktop) {
return platform.storage?.(target.storage)?.removeItem(target.key)
}
if (!target.storage) {
localStorage.removeItem(target.key)
return
}
localStorageWithPrefix(target.storage).removeItem(target.key)
}
export function persisted<T>(
target: string | PersistTarget,
store: [Store<T>, SetStoreFunction<T>],
): PersistedWithReady<T> {
const platform = usePlatform()
const config: PersistTarget = typeof target === "string" ? { key: target } : target
const defaults = snapshot(store[0])
const legacy = config.legacy ?? []
const isDesktop = platform.platform === "desktop" && !!platform.storage
const currentStorage = (() => {
if (isDesktop) return platform.storage?.(config.storage)
if (!config.storage) return localStorage
return localStorageWithPrefix(config.storage)
})()
const legacyStorage = (() => {
if (!isDesktop) return localStorage
if (!config.storage) return platform.storage?.()
return platform.storage?.(LEGACY_STORAGE)
})()
const storage = (() => {
if (!isDesktop) {
const current = currentStorage as SyncStorage
const legacyStore = legacyStorage as SyncStorage
const api: SyncStorage = {
getItem: (key) => {
const raw = current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) current.setItem(key, next)
return next
}
for (const legacyKey of legacy) {
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
current.setItem(key, legacyRaw)
legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) current.setItem(key, next)
return next
}
return null
},
setItem: (key, value) => {
current.setItem(key, value)
},
removeItem: (key) => {
current.removeItem(key)
},
}
return api
}
const current = currentStorage as AsyncStorage
const legacyStore = legacyStorage as AsyncStorage | undefined
const api: AsyncStorage = {
getItem: async (key) => {
const raw = await current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) await current.setItem(key, next)
return next
}
if (!legacyStore) return null
for (const legacyKey of legacy) {
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
await current.setItem(key, legacyRaw)
await legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) await current.setItem(key, next)
return next
}
return null
},
setItem: async (key, value) => {
await current.setItem(key, value)
},
removeItem: async (key) => {
await current.removeItem(key)
},
}
return api
})()
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
// Create a resource that resolves when the store is initialized
// This integrates with Suspense and provides a ready signal
const isAsync = init instanceof Promise
const [ready] = createResource(
() => init,
+184 -29
View File
@@ -1,47 +1,202 @@
import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
import type { Prompt, FileAttachmentPart } from "@/context/prompt"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
type Inline =
| {
type: "file"
start: number
end: number
value: string
path: string
selection?: {
startLine: number
endLine: number
startChar: number
endChar: number
}
}
| {
type: "agent"
start: number
end: number
value: string
name: string
}
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
const queryIndex = url.indexOf("?")
if (queryIndex === -1) return undefined
const params = new URLSearchParams(url.slice(queryIndex + 1))
const startLine = Number(params.get("start"))
const endLine = Number(params.get("end"))
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
return {
startLine,
endLine,
startChar: 0,
endChar: 0,
}
}
function textPartValue(parts: Part[]) {
const candidates = parts
.filter((part): part is TextPart => part.type === "text")
.filter((part) => !part.synthetic && !part.ignored)
return candidates.reduce((best: TextPart | undefined, part) => {
if (!best) return part
if (part.text.length > best.text.length) return part
return best
}, undefined)
}
/**
* Extract prompt content from message parts for restoring into the prompt input.
* This is used by undo to restore the original user prompt.
*/
export function extractPromptFromParts(parts: Part[]): Prompt {
const result: Prompt = []
let position = 0
export function extractPromptFromParts(parts: Part[], opts?: { directory?: string }): Prompt {
const textPart = textPartValue(parts)
const text = textPart?.text ?? ""
const directory = opts?.directory
const toRelative = (path: string) => {
if (!directory) return path
const prefix = directory.endsWith("/") ? directory : directory + "/"
if (path.startsWith(prefix)) return path.slice(prefix.length)
if (path.startsWith(directory)) {
const next = path.slice(directory.length)
if (next.startsWith("/")) return next.slice(1)
return next
}
return path
}
const inline: Inline[] = []
const images: ImageAttachmentPart[] = []
for (const part of parts) {
if (part.type === "text") {
const textPart = part as TextPart
if (!textPart.synthetic && textPart.text) {
result.push({
type: "text",
content: textPart.text,
start: position,
end: position + textPart.text.length,
})
position += textPart.text.length
}
} else if (part.type === "file") {
if (part.type === "file") {
const filePart = part as FilePart
if (filePart.source?.type === "file") {
const path = filePart.source.path
const content = "@" + path
const attachment: FileAttachmentPart = {
type: "file",
path,
content,
start: position,
end: position + content.length,
const sourceText = filePart.source?.text
if (sourceText) {
const value = sourceText.value
const start = sourceText.start
const end = sourceText.end
let path = value
if (value.startsWith("@")) path = value.slice(1)
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
path = filePart.source.path
}
result.push(attachment)
position += content.length
inline.push({
type: "file",
start,
end,
value,
path: toRelative(path),
selection: selectionFromFileUrl(filePart.url),
})
continue
}
if (filePart.url.startsWith("data:")) {
images.push({
type: "image",
id: filePart.id,
filename: filePart.filename ?? "attachment",
mime: filePart.mime,
dataUrl: filePart.url,
})
}
}
if (part.type === "agent") {
const agentPart = part as MessageAgentPart
const source = agentPart.source
if (!source) continue
inline.push({
type: "agent",
start: source.start,
end: source.end,
value: source.value,
name: agentPart.name,
})
}
}
inline.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start
return a.end - b.end
})
const result: Prompt = []
let position = 0
let cursor = 0
const pushText = (content: string) => {
if (!content) return
result.push({
type: "text",
content,
start: position,
end: position + content.length,
})
position += content.length
}
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
const content = item.value
const attachment: FileAttachmentPart = {
type: "file",
path: item.path,
content,
start: position,
end: position + content.length,
selection: item.selection,
}
result.push(attachment)
position += content.length
}
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
const content = item.value
const mention: AgentPart = {
type: "agent",
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(mention)
position += content.length
}
for (const item of inline) {
if (item.start < 0 || item.end < item.start) continue
const expected = item.value
if (!expected) continue
const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
const start = mismatch ? text.indexOf(expected, cursor) : item.start
if (start === -1) continue
const end = mismatch ? start + expected.length : item.end
pushText(text.slice(cursor, start))
if (item.type === "file") pushFile(item)
if (item.type === "agent") pushAgent(item)
cursor = end
}
pushText(text.slice(cursor))
if (result.length === 0) {
result.push({ type: "text", content: "", start: 0, end: 0 })
}
return result
if (images.length === 0) return result
return [...result, ...images]
}
+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",
"version": "1.1.2",
"version": "1.1.6",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "45K",
full: "45,000",
compact: "50K",
full: "50,000",
},
},
@@ -2,6 +2,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -146,6 +147,242 @@ export async function POST(input: APIEvent) {
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
const customerID = body.data.object.customer as string
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
if (!customerID) throw new Error("Customer ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for customer")
await Database.use((tx) =>
tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
}),
)
}
if (body.type === "customer.subscription.created") {
const data = {
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
object: "event",
api_version: "2025-07-30.basil",
created: 1767766916,
data: {
object: {
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
object: "subscription",
application: null,
application_fee_percent: null,
automatic_tax: {
disabled_reason: null,
enabled: false,
liability: null,
},
billing_cycle_anchor: 1770445200,
billing_cycle_anchor_config: null,
billing_mode: {
flexible: {
proration_discounts: "included",
},
type: "flexible",
updated_at: 1770445200,
},
billing_thresholds: null,
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
cancellation_details: {
comment: null,
feedback: null,
reason: null,
},
collection_method: "charge_automatically",
created: 1770445200,
currency: "usd",
customer: "cus_TkKmZZvysJ2wej",
customer_account: null,
days_until_due: null,
default_payment_method: null,
default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
default_tax_rates: [],
description: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: "self",
},
},
items: {
object: "list",
data: [
{
id: "si_TkKnBKXFX76t0O",
object: "subscription_item",
billing_thresholds: null,
created: 1770445200,
current_period_end: 1772864400,
current_period_start: 1770445200,
discounts: [],
metadata: {},
plan: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "plan",
active: true,
amount: 20000,
amount_decimal: "20000",
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
interval: "month",
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: "licensed",
},
price: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "price",
active: true,
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
recurring: {
interval: "month",
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: "licensed",
},
tax_behavior: "unspecified",
tiers_mode: null,
transform_quantity: null,
type: "recurring",
unit_amount: 20000,
unit_amount_decimal: "20000",
},
quantity: 1,
subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
tax_rates: [],
},
],
has_more: false,
total_count: 1,
url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
},
latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
livemode: false,
metadata: {},
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: {
payment_method_options: null,
payment_method_types: null,
save_default_payment_method: "off",
},
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
plan: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "plan",
active: true,
amount: 20000,
amount_decimal: "20000",
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
interval: "month",
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: "licensed",
},
quantity: 1,
schedule: null,
start_date: 1770445200,
status: "active",
test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
transfer_data: null,
trial_end: null,
trial_settings: {
end_behavior: {
missing_payment_method: "create_invoice",
},
},
trial_start: null,
},
},
livemode: false,
pending_webhooks: 0,
request: {
id: "req_6YO9stvB155WJD",
idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
},
type: "customer.subscription.created",
}
}
if (body.type === "customer.subscription.deleted") {
const subscriptionID = body.data.object.id
if (!subscriptionID) throw new Error("Subscription ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
await Database.transaction(async (tx) => {
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
})
}
})()
.then((message) => {
return Response.json({ message: message ?? "done" }, { status: 200 })
@@ -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 { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
export default function () {
const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id!))
const sessionInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={userInfo()?.isAdmin}>
<Show when={sessionInfo()?.isAdmin}>
<Show when={billingInfo()?.subscriptionID}>
<BlackSection />
</Show>
<BillingSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />
@@ -45,6 +45,19 @@
text-decoration: line-through;
}
}
&[data-slot="payment-receipt"] {
span {
display: inline-block;
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
line-height: 1.5;
}
button {
font-size: var(--font-size-sm);
}
}
}
tbody tr {
@@ -61,13 +74,17 @@
}
th {
&:nth-child(2) /* Payment ID */ {
&:nth-child(2)
/* Payment ID */ {
display: none;
}
}
td {
&:nth-child(2) /* Payment ID */ {
&:nth-child(2)
/* Payment ID */ {
display: none;
}
}
@@ -77,6 +77,7 @@ export function PaymentSection() {
<For each={payments()!}>
{(payment) => {
const date = new Date(payment.timeCreated)
const isCredit = !payment.paymentID
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
@@ -85,19 +86,24 @@ export function PaymentSection() {
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((payment.amount ?? 0) / 100000000).toFixed(2)}
{isCredit ? " (credit)" : ""}
</td>
<td data-slot="payment-receipt">
<button
onClick={async () => {
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
if (receiptUrl) {
window.open(receiptUrl, "_blank")
}
}}
data-slot="receipt-button"
>
View
</button>
{isCredit ? (
<span>-</span>
) : (
<button
onClick={async () => {
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
if (receiptUrl) {
window.open(receiptUrl, "_blank")
}
}}
data-slot="receipt-button"
>
View
</button>
)}
</td>
</tr>
)
@@ -169,7 +169,9 @@ export function UsageSection() {
</Show>
</div>
</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
<td data-slot="usage-cost">
${usage.enrichment?.plan === "sub" ? "0.0000" : ((usage.cost ?? 0) / 100000000).toFixed(4)}
</td>
</tr>
)
}}
@@ -1,6 +1,13 @@
export class AuthError extends Error {}
export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class SubscriptionError extends Error {
retryAfter?: number
constructor(message: string, retryAfter?: number) {
super(message)
this.retryAfter = retryAfter
}
}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class RateLimitError extends Error {}
@@ -8,11 +8,20 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ZenData } from "@opencode-ai/console-core/model.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
import {
AuthError,
CreditsError,
MonthlyLimitError,
SubscriptionError,
UserLimitError,
ModelError,
RateLimitError,
} from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
@@ -69,13 +78,13 @@ export async function handler(
const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
const isTrial = await trialLimiter?.isTrial()
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip)
await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId)
const stickyProvider = await stickyTracker?.get()
const authInfo = await authenticate(modelInfo)
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const authInfo = await authenticate(modelInfo)
const providerInfo = selectProvider(
zenData,
authInfo,
@@ -135,10 +144,10 @@ export async function handler(
})
}
return { providerInfo, authInfo, reqBody, res, startTimestamp }
return { providerInfo, reqBody, res, startTimestamp }
}
const { providerInfo, authInfo, reqBody, res, startTimestamp } = await retriableRequest()
const { providerInfo, reqBody, res, startTimestamp } = await retriableRequest()
// Store model request
dataDumper?.provideModel(providerInfo.storeModel)
@@ -172,8 +181,8 @@ export async function handler(
const tokensInfo = providerInfo.normalizeUsage(json.usage)
await trialLimiter?.track(tokensInfo)
await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo)
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo, costInfo)
return new Response(body, {
status: resStatus,
statusText: res.statusText,
@@ -206,8 +215,8 @@ export async function handler(
if (usage) {
const tokensInfo = providerInfo.normalizeUsage(usage)
await trialLimiter?.track(tokensInfo)
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo)
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo, costInfo)
}
c.close()
return
@@ -279,14 +288,19 @@ export async function handler(
{ status: 401 },
)
if (error instanceof RateLimitError)
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
const headers = new Headers()
if (error instanceof SubscriptionError && error.retryAfter) {
headers.set("retry-after", String(error.retryAfter))
}
return new Response(
JSON.stringify({
type: "error",
error: { type: error.constructor.name, message: error.message },
}),
{ status: 429 },
{ status: 429, headers },
)
}
return new Response(
JSON.stringify({
@@ -392,6 +406,7 @@ export async function handler(
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
reloadTrigger: BillingTable.reloadTrigger,
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
},
user: {
id: UserTable.id,
@@ -399,6 +414,13 @@ export async function handler(
monthlyUsage: UserTable.monthlyUsage,
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
},
subscription: {
timeSubscribed: UserTable.timeSubscribed,
subIntervalUsage: UserTable.subIntervalUsage,
subMonthlyUsage: UserTable.subMonthlyUsage,
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
},
provider: {
credentials: ProviderTable.credentials,
},
@@ -426,6 +448,7 @@ export async function handler(
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
isSubscription: data.subscription.timeSubscribed ? true : false,
})
return {
@@ -433,6 +456,7 @@ export async function handler(
workspaceID: data.workspaceID,
billing: data.billing,
user: data.user,
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
provider: data.provider,
isFree: FREE_WORKSPACES.includes(data.workspaceID),
isDisabled: !!data.timeDisabled,
@@ -445,6 +469,64 @@ export async function handler(
if (authInfo.isFree) return
if (modelInfo.allowAnonymous) return
// Validate subscription billing
if (authInfo.subscription) {
const black = BlackData.get()
const sub = authInfo.subscription
const now = new Date()
const formatRetryTime = (seconds: number) => {
const days = Math.floor(seconds / 86400)
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
const hours = Math.floor(seconds / 3600)
const minutes = Math.ceil((seconds % 3600) / 60)
if (hours >= 1) return `${hours}hr ${minutes}min`
return `${minutes}min`
}
// Check monthly limit (based on subscription billing cycle)
if (
sub.subMonthlyUsage &&
sub.timeSubMonthlyUsageUpdated &&
sub.subMonthlyUsage >= centsToMicroCents(black.monthlyLimit * 100)
) {
const subscribeDay = sub.timeSubscribed!.getUTCDate()
const cycleStart = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
subscribeDay,
),
)
const cycleEnd = new Date(Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay))
if (sub.timeSubMonthlyUsageUpdated >= cycleStart && sub.timeSubMonthlyUsageUpdated < cycleEnd) {
const retryAfter = Math.ceil((cycleEnd.getTime() - now.getTime()) / 1000)
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter,
)
}
}
// Check interval limit
const intervalMs = black.intervalLength * 3600 * 1000
if (sub.subIntervalUsage && sub.timeSubIntervalUsageUpdated) {
const currentInterval = Math.floor(now.getTime() / intervalMs)
const usageInterval = Math.floor(sub.timeSubIntervalUsageUpdated.getTime() / intervalMs)
if (currentInterval === usageInterval && sub.subIntervalUsage >= centsToMicroCents(black.intervalLimit * 100)) {
const nextInterval = (currentInterval + 1) * intervalMs
const retryAfter = Math.ceil((nextInterval - now.getTime()) / 1000)
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter,
)
}
}
return
}
// Validate pay as you go billing
const billing = authInfo.billing
if (!billing.paymentMethodID)
throw new CreditsError(
@@ -462,29 +544,25 @@ export async function handler(
billing.monthlyLimit &&
billing.monthlyUsage &&
billing.timeMonthlyUsageUpdated &&
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
) {
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new MonthlyLimitError(
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
)
}
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) &&
currentYear === billing.timeMonthlyUsageUpdated.getUTCFullYear() &&
currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
)
throw new MonthlyLimitError(
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
)
if (
authInfo.user.monthlyLimit &&
authInfo.user.monthlyUsage &&
authInfo.user.timeMonthlyUsageUpdated &&
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100)
) {
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new UserLimitError(
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
)
}
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) &&
currentYear === authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() &&
currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
)
throw new UserLimitError(
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
)
}
function validateModelSettings(authInfo: AuthInfo) {
@@ -559,61 +637,110 @@ export async function handler(
if (!authInfo) return
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
id: Identifier.create("usage"),
model: modelInfo.id,
provider: providerInfo.id,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
cost,
keyID: authInfo.apiKeyId,
})
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.use((db) =>
Promise.all([
db.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
id: Identifier.create("usage"),
model: modelInfo.id,
provider: providerInfo.id,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
cost,
keyID: authInfo.apiKeyId,
enrichment: authInfo.subscription ? { plan: "sub" } : undefined,
}),
db
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
...(authInfo.subscription
? (() => {
const now = new Date()
const subscribeDay = authInfo.subscription.timeSubscribed!.getUTCDate()
const cycleStart = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
subscribeDay,
),
)
const cycleEnd = new Date(
Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay),
)
return [
db
.update(UserTable)
.set({
subMonthlyUsage: sql`
CASE
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeSubMonthlyUsageUpdated: sql`now()`,
subIntervalUsage: sql`
CASE
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost}
ELSE ${cost}
END
`,
timeSubIntervalUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
]
})()
: [
db
.update(BillingTable)
.set({
balance: authInfo.isFree
? sql`${BillingTable.balance} - ${0}`
: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID))
await tx
.update(UserTable)
.set({
monthlyUsage: sql`
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
db
.update(UserTable)
.set({
monthlyUsage: sql`
CASE
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
]),
]),
)
return { costInMicroCents: cost }
}
async function reload(authInfo: AuthInfo) {
async function reload(authInfo: AuthInfo, costInfo: Awaited<ReturnType<typeof trackUsage>>) {
if (!authInfo) return
if (authInfo.isFree) return
if (authInfo.provider?.credentials) return
if (authInfo.subscription) return
if (!costInfo) return
const reloadTrigger = centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100)
if (authInfo.billing.balance - costInfo.costInMicroCents >= reloadTrigger) return
if (authInfo.billing.timeReloadLockedTill && authInfo.billing.timeReloadLockedTill > new Date()) return
const lock = await Database.use((tx) =>
tx
@@ -625,10 +752,7 @@ export async function handler(
and(
eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true),
lt(
BillingTable.balance,
centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
),
lt(BillingTable.balance, reloadTrigger),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
),
),
@@ -1,28 +1,34 @@
import { Resource } from "@opencode-ai/console-resource"
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error"
import { logger } from "./logger"
export function createRateLimiter(model: string, limit: number | undefined, ip: string) {
export function createRateLimiter(limit: number | undefined, rawIp: string) {
if (!limit) return
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
const currKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now)}`
const prevKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now - 3_600_000)}`
let currRate: number
let prevRate: number
const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
return {
track: async () => {
await Resource.GatewayKv.put(currKey, currRate + 1, { expirationTtl: 3600 })
await Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values({ ip, interval: intervals[0], count: 1 })
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
)
},
check: async () => {
const values = await Resource.GatewayKv.get([currKey, prevKey])
const prevValue = values?.get(prevKey)
const currValue = values?.get(currKey)
prevRate = prevValue ? parseInt(prevValue) : 0
currRate = currValue ? parseInt(currValue) : 0
logger.debug(`rate limit ${model} prev/curr: ${prevRate}/${currRate}`)
if (prevRate + currRate >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
const rows = await Database.use((tx) =>
tx
.select({ count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))),
)
const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`)
if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
},
}
}
@@ -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,
"tag": "0040_broken_gamora",
"breakpoints": true
},
{
"idx": 41,
"version": "5",
"when": 1767732559197,
"tag": "0041_odd_misty_knight",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1767744077346,
"tag": "0042_flat_nightmare",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1767752636118,
"tag": "0043_lame_calypso",
"breakpoints": true
},
{
"idx": 44,
"version": "5",
"when": 1767759322451,
"tag": "0044_tiny_captain_midlands",
"breakpoints": true
},
{
"idx": 45,
"version": "5",
"when": 1767765497502,
"tag": "0045_cuddly_diamondback",
"breakpoints": true
}
]
}
+4 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.2",
"version": "1.1.6",
"private": true,
"type": "module",
"license": "MIT",
@@ -32,6 +32,9 @@
"promote-models-to-dev": "script/promote-models.ts dev",
"promote-models-to-prod": "script/promote-models.ts production",
"pull-models-from-dev": "script/pull-models.ts dev",
"update-black": "script/update-black.ts",
"promote-black-to-dev": "script/promote-black.ts dev",
"promote-black-to-prod": "script/promote-black.ts production",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
@@ -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 { AuthTable } from "../src/schema/auth.sql"
import { Database, eq, sql, inArray } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
// get input from command line
const email = process.argv[2]
if (!email) {
console.error("Usage: bun lookup-user.ts <email>")
const identifier = process.argv[2]
if (!identifier) {
console.error("Usage: bun lookup-user.ts <email|workspaceID>")
process.exit(1)
}
const authData = await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.subject, email)))
if (authData.length === 0) {
console.error("User not found")
process.exit(1)
if (identifier.startsWith("wrk_")) {
await printWorkspace(identifier)
} else {
const authData = await Database.use(async (tx) =>
tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
)
if (authData.length === 0) {
console.error("Email not found")
process.exit(1)
}
if (authData.length > 1) console.warn("Multiple users found for email", identifier)
// Get all auth records for email
const accountID = authData[0].accountID
await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID)))
// Get all workspaces for this account
const users = await printTable("Workspaces", (tx) =>
tx
.select({
userID: UserTable.id,
workspaceID: UserTable.workspaceID,
workspaceName: WorkspaceTable.name,
role: UserTable.role,
})
.from(UserTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.where(eq(UserTable.accountID, accountID)),
)
// Get all payments for these workspaces
await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID)))
}
await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID)))
async function printWorkspace(workspaceID: string) {
const workspace = await Database.use((tx) =>
tx
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0]),
)
function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any[]>): Promise<any[]> {
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
await printTable("Billing", (tx) =>
tx
.select({
balance: BillingTable.balance,
customerID: BillingTable.customerID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id))
.then(
(rows) =>
rows.map((row) => ({
...row,
balance: `$${(row.balance / 100000000).toFixed(2)}`,
}))[0],
),
)
await printTable("Payments", (tx) =>
tx
.select({
amount: PaymentTable.amount,
paymentID: PaymentTable.paymentID,
invoiceID: PaymentTable.invoiceID,
timeCreated: PaymentTable.timeCreated,
timeRefunded: PaymentTable.timeRefunded,
})
.from(PaymentTable)
.where(eq(PaymentTable.workspaceID, workspace.id))
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
.limit(100)
.then((rows) =>
rows.map((row) => ({
...row,
amount: `$${(row.amount / 100000000).toFixed(2)}`,
paymentID: row.paymentID
? `https://dashboard.stripe.com/acct_1RszBH2StuRr0lbX/payments/${row.paymentID}`
: null,
})),
),
)
await printTable("Usage", (tx) =>
tx
.select({
model: UsageTable.model,
provider: UsageTable.provider,
inputTokens: UsageTable.inputTokens,
outputTokens: UsageTable.outputTokens,
reasoningTokens: UsageTable.reasoningTokens,
cacheReadTokens: UsageTable.cacheReadTokens,
cacheWrite5mTokens: UsageTable.cacheWrite5mTokens,
cacheWrite1hTokens: UsageTable.cacheWrite1hTokens,
cost: UsageTable.cost,
timeCreated: UsageTable.timeCreated,
})
.from(UsageTable)
.where(eq(UsageTable.workspaceID, workspace.id))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(10)
.then((rows) =>
rows.map((row) => ({
...row,
cost: `$${(row.cost / 100000000).toFixed(2)}`,
})),
),
)
}
function printHeader(title: string) {
console.log()
console.log("─".repeat(title.length))
console.log(`${title}`)
console.log("─".repeat(title.length))
}
function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any>): Promise<any> {
return Database.use(async (tx) => {
const data = await callback(tx)
console.log(`== ${title} ==`)
console.table(data)
console.log(`\n== ${title} ==`)
if (data.length === 0) {
console.log("(no data)")
} else {
console.table(data)
}
return data
})
}
@@ -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
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType,
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) => {
return await Database.use((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 { workspaceIndexes } from "./workspace.sql"
@@ -21,8 +21,13 @@ export const BillingTable = mysqlTable(
reloadError: varchar("reload_error", { length: 255 }),
timeReloadError: utc("time_reload_error"),
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(
@@ -54,6 +59,9 @@ export const UsageTable = mysqlTable(
cacheWrite1hTokens: int("cache_write_1h_tokens"),
cost: bigint("cost", { mode: "number" }).notNull(),
keyID: ulid("key_id"),
enrichment: json("enrichment").$type<{
plan: "sub"
}>(),
},
(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] })],
)
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"),
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
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) => [
...workspaceIndexes(table),
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.2",
"version": "1.1.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.2",
"version": "1.1.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"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",
"private": true,
"version": "1.1.2",
"version": "1.1.6",
"type": "module",
"license": "MIT",
"scripts": {
@@ -14,6 +14,7 @@
},
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
+35
View File
@@ -1177,6 +1177,21 @@ dependencies = [
"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]]
name = "futures-channel"
version = "0.3.31"
@@ -1184,6 +1199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1251,6 +1267,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -2775,8 +2792,10 @@ dependencies = [
name = "opencode-desktop"
version = "0.0.0"
dependencies = [
"futures",
"gtk",
"listeners",
"semver",
"serde",
"serde_json",
"tauri",
@@ -2789,6 +2808,7 @@ dependencies = [
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
@@ -4636,6 +4656,21 @@ dependencies = [
"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]]
name = "tauri-plugin-store"
version = "2.4.1"
+3
View File
@@ -29,12 +29,15 @@ tauri-plugin-window-state = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-http = "2"
tauri-plugin-notification = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = "1.48.0"
listeners = "0.3"
tauri-plugin-os = "2"
futures = "0.3.31"
semver = "1.0.27"
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
@@ -12,5 +12,19 @@
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<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>
</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(())
}
+203 -98
View File
@@ -1,27 +1,110 @@
mod cli;
mod window_customizer;
use cli::{get_sidecar_path, install_cli, sync_cli};
use futures::FutureExt;
use std::{
collections::VecDeque,
net::{SocketAddr, TcpListener},
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri::{
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
WebviewWindow,
};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use tauri_plugin_store::StoreExt;
use tokio::net::TcpSocket;
use crate::window_customizer::PinchZoomDisablePlugin;
#[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)]
struct LogState(Arc<Mutex<VecDeque<String>>>);
const MAX_LOG_ENTRIES: usize = 200;
const GLOBAL_STORAGE: &str = "opencode.global.dat";
/// Check if a URL's origin matches any configured server in the store.
/// Returns true if the URL should be allowed for internal navigation.
fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
// Always allow localhost and 127.0.0.1
if let Some(host) = url.host_str() {
if host == "localhost" || host == "127.0.0.1" {
return true;
}
}
// Try to read the server list from the store
let Ok(store) = app.store(GLOBAL_STORAGE) else {
return false;
};
let Some(server_data) = store.get("server") else {
return false;
};
// Parse the server list from the stored JSON
let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
return false;
};
// Get the origin of the navigation URL (scheme + host + port)
let url_origin = format!(
"{}://{}{}",
url.scheme(),
url.host_str().unwrap_or(""),
url.port().map(|p| format!(":{}", p)).unwrap_or_default()
);
// Check if any configured server matches the URL's origin
for server in list {
let Some(server_url) = server.as_str() else {
continue;
};
// Parse the server URL to extract its origin
let Ok(parsed) = tauri::Url::parse(server_url) else {
continue;
};
let server_origin = format!(
"{}://{}{}",
parsed.scheme(),
parsed.host_str().unwrap_or(""),
parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
);
if url_origin == server_origin {
return true;
}
}
false
}
#[tauri::command]
fn kill_sidecar(app: AppHandle) {
@@ -31,7 +114,7 @@ fn kill_sidecar(app: AppHandle) {
};
let Some(server_state) = server_state
.0
.child
.lock()
.expect("Failed to acquire mutex lock")
.take()
@@ -45,25 +128,6 @@ fn kill_sidecar(app: AppHandle) {
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> {
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
@@ -75,6 +139,15 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
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 {
option_env!("OPENCODE_PORT")
.map(|s| s.to_string())
@@ -116,11 +189,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
#[cfg(not(target_os = "windows"))]
let (mut rx, child) = {
let sidecar_path = tauri::utils::platform::current_exe()
.expect("Failed to get current exe")
.parent()
.expect("Failed to get parent dir")
.join("opencode-cli");
let sidecar = get_sidecar_path();
let shell = get_user_shell();
app.shell()
.command(&shell)
@@ -130,7 +199,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
.args([
"-il",
"-c",
&format!("{} serve --port={}", sidecar_path.display(), port),
&format!("\"{}\" serve --port={}", sidecar.display(), port),
])
.spawn()
.expect("Failed to spawn opencode")
@@ -189,6 +258,13 @@ pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
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_window_state::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build())
@@ -202,8 +278,8 @@ pub fn run() {
.plugin(PinchZoomDisablePlugin)
.invoke_handler(tauri::generate_handler![
kill_sidecar,
copy_logs_to_clipboard,
get_logs
install_cli,
ensure_server_started
])
.setup(move |app| {
let app = app.handle().clone();
@@ -211,83 +287,112 @@ pub fn run() {
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
tauri::async_runtime::spawn(async move {
let port = get_sidecar_port();
// Get port and create window immediately for faster perceived startup
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 {
let child = spawn_sidecar(&app, port);
// Create window immediately with serverReady = false
let app_for_nav = app.clone();
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()
.on_navigation(move |url| {
// Allow internal navigation (tauri:// scheme)
if url.scheme() == "tauri" {
return true;
}
// Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
if is_allowed_server(&app_for_nav, url) {
return true;
}
// Open external http/https URLs in default browser
if url.scheme() == "http" || url.scheme() == "https" {
let _ = app_for_nav.shell().open(url.as_str(), None);
return false; // Cancel internal navigation
}
true
})
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"#
));
let timestamp = Instant::now();
loop {
if timestamp.elapsed() > Duration::from_secs(7) {
let res = app.dialog()
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
.title("Startup Failed")
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
.blocking_show_with_result();
#[cfg(target_os = "macos")]
{
window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true)
.traffic_light_position(tauri::LogicalPosition::new(16.0, 19.5));
}
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
match copy_logs_to_clipboard(app.clone()).await {
Ok(()) => println!("Logs copied to clipboard successfully"),
Err(e) => println!("Failed to copy logs to clipboard: {}", e),
}
let window = window_builder.build().expect("Failed to create window");
let (tx, rx) = tokio::sync::oneshot::channel();
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;
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 {
None
};
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))));
});
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = sync_cli(app) {
eprintln!("Failed to sync CLI: {e}");
}
});
}
Ok(())
});

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