Compare commits

...

228 Commits

Author SHA1 Message Date
opencode
85284df725 release: v1.0.98 2025-11-22 06:00:23 +00:00
Dax Raad
87054ee983 fix flickering/layout shift during work 2025-11-22 00:49:35 -05:00
GitHub Action
81245c2548 chore: format code 2025-11-22 05:15:55 +00:00
Dax Raad
6f82b321d8 tauri 2025-11-22 00:15:01 -05:00
opencode
f4593c6653 release: v1.0.97 2025-11-22 05:02:16 +00:00
Dax Raad
15902cf54d core: add missing system libraries to docker image so the agent starts successfully 2025-11-21 23:52:01 -05:00
GitHub Action
d1102c33ac chore: format code 2025-11-22 04:48:52 +00:00
Dax Raad
aabbd3383c fix dockerfile 2025-11-21 23:48:09 -05:00
opencode
afb55cb7d4 release: v1.0.95 2025-11-22 04:27:20 +00:00
Dax Raad
ade794a937 ci: ignore 2025-11-21 23:08:35 -05:00
Dax Raad
34271a82ff release: v1.0.94 2025-11-21 23:06:37 -05:00
Dax Raad
b20a31098a sync 2025-11-21 22:58:20 -05:00
Dax Raad
b5a039e5ae ci stuff 2025-11-21 22:53:58 -05:00
GitHub Action
986cc0a01c chore: format code 2025-11-22 03:31:29 +00:00
opencode
b20fd36c48 release: v1.0.92 2025-11-22 03:31:28 +00:00
Dax Raad
e4e6bf66e1 publish tar.gz for linux 2025-11-21 22:24:36 -05:00
opencode
3ae27273c6 release: v1.0.91 2025-11-22 02:19:56 +00:00
Dax Raad
eefb3c43dd fix arg parsing 2025-11-21 21:13:43 -05:00
Github Action
cc229e726e Update Nix flake.lock and hashes 2025-11-22 01:43:03 +00:00
Dax
49408c00e9 enterprise (#4617)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-11-21 20:41:27 -05:00
opencode
76192fbced release: v1.0.90 2025-11-22 00:16:10 +00:00
GitHub Action
a1c76c79de chore: format code 2025-11-22 00:09:32 +00:00
Dax Raad
db9e2b1aac ci: disable automatic config loading during CLI builds to prevent configuration interference 2025-11-21 19:08:51 -05:00
opencode
45c4970d68 release: v1.0.89 2025-11-22 00:00:23 +00:00
Tommy D. Rossi
1d7a9309d6 feat: lower opacity for thinking summaries (#4610) 2025-11-21 17:35:47 -06:00
Dax Raad
f5ac98251e tui: split revert memo into smaller tracked computations to prevent unnecessary re-evaluations 2025-11-21 18:27:25 -05:00
GitHub Action
78239045ba chore: format code 2025-11-21 22:42:39 +00:00
Dax Raad
d8b60875c4 tui: batch SDK events to reduce render churn and improve responsiveness
Intelligently queue events and flush them in 16ms batches. Events processed within 16ms of the last flush are batched together to reduce the number of store updates and re-renders, preventing jank when receiving rapid consecutive events. Events after a 16ms gap are processed immediately to avoid adding latency.
2025-11-21 17:42:02 -05:00
opencode
48949a6e9d release: v1.0.88 2025-11-21 22:37:46 +00:00
GitHub Action
082a330ea3 chore: format code 2025-11-21 22:30:46 +00:00
Aiden Cline
adf7df0d5c tweak: bash tool behavior w/ /bin/zsh 2025-11-21 16:30:00 -06:00
opencode
a76ad48563 release: v1.0.87 2025-11-21 21:58:10 +00:00
Dax Raad
00f991162f if finish reason is unknown, continue 2025-11-21 16:51:32 -05:00
Frank
d6cdd24fad doc: update gpt pricing 2025-11-21 15:48:54 -05:00
GitHub Action
c9473756df chore: format code 2025-11-21 20:33:42 +00:00
Ivan Starkov
b5d0c56b4c fix: make bash tool respect $SHELL (#3494)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-21 14:33:04 -06:00
opencode
96a2f5268c release: v1.0.86 2025-11-21 20:32:47 +00:00
Aiden Cline
5083f9c9c2 bump bun to 1.3.3 2025-11-21 13:56:46 -06:00
Frank
e34df15ff5 update logic for local and dev providers 2025-11-21 13:31:33 -05:00
GitHub Action
26ec87803a chore: format code 2025-11-21 17:57:33 +00:00
Frank
037e8d4555 wip: zen 2025-11-21 12:50:51 -05:00
Adam
08a366c4dc feat(install): better install script visuals, custom progress bar and next steps (#4589) 2025-11-21 07:29:52 -06:00
GitHub Action
670e1523e0 ignore: update download stats 2025-11-21 2025-11-21 12:04:49 +00:00
Patrick Wolf
416f2964b5 fix: preserve agent context during compaction (#4556) 2025-11-21 02:13:10 -06:00
Ian Maurer
e018e16898 fix(cli): ensure clean exit on provider/model errors (#4223)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-21 02:01:19 -06:00
Aiden Cline
d16c8c9f0f ignore: update sdk 2025-11-21 01:25:06 -06:00
Aiden Cline
fffe20cbe5 add provider whitelist 2025-11-21 01:24:44 -06:00
Aiden Cline
f6da3c467b ignore: sync sdk 2025-11-21 01:04:02 -06:00
geril07
c0d9f21c0f feat: whitelist/blacklist config options for provider (#3416)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-21 01:01:30 -06:00
GitHub Action
a67b616139 chore: format code 2025-11-21 06:03:38 +00:00
opencode
2991547974 release: v1.0.85 2025-11-21 06:03:38 +00:00
Dax Raad
b59def2e4a hide gpt5 nano 2025-11-21 00:58:02 -05:00
opencode
d842353f39 release: v1.0.84 2025-11-21 05:55:03 +00:00
Dax Raad
d20ef569de tui: replace text shimmer with animated progress bar during model processing 2025-11-21 00:47:27 -05:00
Dax Raad
ca2b871810 tui: simplify popular providers list to show only top 6 instead of applying provider priority sorting 2025-11-21 00:32:21 -05:00
Dax
23ea8ba1ce Tui onboarding (#4569)
Co-authored-by: GitHub Action <action@github.com>
2025-11-21 00:21:06 -05:00
Zak
c417fec246 tweak: adjust invalid directory error message (#4567)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-20 22:36:07 -06:00
Aiden Cline
5413b16b57 fix: split not a function error 2025-11-20 22:33:59 -06:00
Spoon
43c021ed80 feat: add field to allow user only messages (#4554)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-20 19:13:42 -06:00
GitHub Action
3b005d29d7 chore: format code 2025-11-21 00:49:39 +00:00
opencode
635f70f477 release: v1.0.83 2025-11-21 00:49:39 +00:00
Aiden Cline
adbb6037ac fix: undefined err 2025-11-20 18:43:58 -06:00
opencode
598d6d00e4 release: v1.0.82 2025-11-20 22:44:37 +00:00
Aiden Cline
cf934357c9 fix: make use openrouter ai sdk package to fix interlevened thinking models 2025-11-20 16:32:44 -06:00
Jay
8063e645c7 docs: Clarify model provider recommendations in README
Updated the README to clarify model provider recommendations.
2025-11-20 17:21:19 -05:00
Aiden Cline
8ab206b443 tweak: navigate to child session if it is asking for permissions 2025-11-20 15:46:49 -06:00
Aiden Cline
ec5c96e10d Revert "Image tag fix for real this time (#4540)"
This reverts commit eb975bb89c.
2025-11-20 14:14:02 -06:00
Aiden Cline
d2a61290b9 Revert "ignore: write todo"
This reverts commit 5aa2078852.
2025-11-20 14:13:56 -06:00
Aiden Cline
10faf9e717 ignore: fix mdx 2025-11-20 14:07:25 -06:00
Aiden Cline
cba239bc8f change default child session cycle keybind to <leader>right, and <leader>left 2025-11-20 14:01:02 -06:00
jian
6f5e3ddfb3 chore: rm empty file (#4552) 2025-11-20 13:33:09 -06:00
GitHub Action
d412ba264a chore: format code 2025-11-20 19:17:06 +00:00
Aiden Cline
9780f2b792 docs: update model list 2025-11-20 13:16:27 -06:00
Aiden Cline
5aa2078852 ignore: write todo 2025-11-20 12:35:34 -06:00
shuv
eb975bb89c Image tag fix for real this time (#4540)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-20 12:34:39 -06:00
GitHub Action
9479fe3ce6 chore: format code 2025-11-20 17:35:43 +00:00
William Tan
4393cf8dbe tweak: Prefer VISUAL environment variable over EDITOR per Unix convention (#4549)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-20 11:27:16 -06:00
opencode
447a4ca8c3 release: v1.0.81 2025-11-20 17:16:31 +00:00
Aiden Cline
40ac2549ff fix: aur build 2025-11-20 11:04:09 -06:00
cc-bjojac
a9c56b813a Make the MAX_OUTPUT_LENGTH in bash.ts overridable by OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH (#4497)
Co-authored-by: Björn Jacobs <bjoern.jacobs.ext@bundesanzeiger.de>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-20 10:45:13 -06:00
Haris Gušić
b1b73c9deb fix: Autocomplete with existing space after trigger (#4121)
Co-authored-by: GitHub Action <action@github.com>
2025-11-20 10:37:45 -06:00
Aiden Cline
774377330b fix: lockfile 2025-11-20 10:16:34 -06:00
processtrader
e7a157ef8f fix: replace ruby-lsp with rubocop for better LSP performance (#4543) 2025-11-20 09:54:47 -06:00
Frank
3989b9fc7f wip: zen 2025-11-20 10:31:35 -05:00
GitHub Action
8bfcdf4831 chore: format code 2025-11-20 14:55:06 +00:00
Frank
3632ba3785 zen: show token breakdown 2025-11-20 09:54:22 -05:00
Albert O'Shea
b7b3824d76 nix: update flake.lock as part of the github workflow (#4535)
Co-authored-by: Github Action <action@github.com>
2025-11-20 08:43:13 -06:00
Tommy D. Rossi
b12efb2023 fix: do not print ai sdk warnings (#4541)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-20 08:42:24 -06:00
Roberto Gongora
bd91cf220c docs: typo in PHP Intelephense documentation (#4545) 2025-11-20 08:41:31 -06:00
Asmae_ELAZRAK
9eb6731c21 docs: add cortecs docs (#4547) 2025-11-20 08:34:04 -06:00
GitHub Action
11373755d9 ignore: update download stats 2025-11-20 2025-11-20 12:04:55 +00:00
Aiden Cline
00b5e9f6ca chore: rm unused code 2025-11-20 00:10:51 -06:00
Aiden Cline
6b3f424e4d tweak: let user name export file 2025-11-19 23:58:40 -06:00
Aiden Cline
e7dfeec9c4 fix: make toast wrap 2025-11-19 22:50:27 -06:00
Aiden Cline
97893bd7e6 tweak: make /export go to cwd 2025-11-19 22:45:40 -06:00
Frank
bfefdb3752 zen: add gemini icon 2025-11-19 21:14:31 -05:00
Frank
12b79c581e zen: update gemini provider name 2025-11-19 20:58:22 -05:00
Frank
ac9b4c7ebf docs: add gemini 3 pricing 2025-11-19 20:50:43 -05:00
GitHub Action
208af232ff chore: format code 2025-11-20 01:27:50 +00:00
opencode
600c6b4973 release: v1.0.80 2025-11-20 01:27:49 +00:00
Dax Raad
61007a9b94 refactor: switch to Switch/Match pattern for assistant message status rendering 2025-11-19 20:18:15 -05:00
opencode
52fe1a5ac5 release: v1.0.79 2025-11-20 01:11:20 +00:00
althafdemiandra
468927e06a chore: bump ai-sdk to v5.0.97 (#4518)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-19 18:44:33 -06:00
Aiden Cline
61562dd9f0 make aur build check if u are glibc system or a musl system (#4519) 2025-11-19 18:36:33 -06:00
Github Action
c86dd91310 Update Nix hashes 2025-11-19 23:30:37 +00:00
Sebastian Herrlinger
9c85a37811 bump opentui version to v0.1.47
- fixing cursor issues with some graphemes in textarea
- proper suspend/resume
2025-11-20 00:28:25 +01:00
Aiden Cline
51bba6e634 tweak: default to disabling fetch timeout in provider options 2025-11-19 16:20:29 -06:00
Daniel Polito
e1089bc5de Adding LSP: PHP Intelephense (#4504)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-19 16:01:18 -06:00
Aiden Cline
618c654aa0 ignore: todo fix test case 2025-11-19 15:18:21 -06:00
Iljo
4703e859bd Add YAML language server support (#4508)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-19 14:47:04 -06:00
Aiden Cline
a1dc4ebbe4 ignore: flaky test be a lil less flaky plz 2025-11-19 14:46:06 -06:00
Aiden Cline
e4e6096510 ignore: fix hanging test 2025-11-19 14:38:12 -06:00
Aiden Cline
c472734933 tweak: make getUsage function handle missing usage data 2025-11-19 14:29:19 -06:00
Aiden Cline
9d068c20bb fix: openrouter ai sdk package support 2025-11-19 14:22:51 -06:00
Aiden Cline
48e4f2f45d tweak: add bun install retries 2025-11-19 13:04:20 -06:00
Aiden Cline
bbf4574476 fix: make external_directory permission wildcarding more sane 2025-11-19 12:55:02 -06:00
Adam
8bad513140 Revert "feat(cli): better install script output"
This reverts commit 24bb293136.
2025-11-19 12:44:35 -06:00
Aiden Cline
1ff5d888c2 fix: make bash tool use external_directory perm 2025-11-19 12:31:34 -06:00
Dax Raad
5d25758400 use bash description as task title 2025-11-19 13:23:29 -05:00
Dax
16fdc90976 fix: resolve issue 4475 (#4505) 2025-11-19 13:10:09 -05:00
Aiden Cline
793542230f tweak: bash description 2025-11-19 11:31:12 -06:00
Tommy D. Rossi
9de1242d9b fix: show reasoning summaries for gemini models (#4491)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-19 11:25:18 -06:00
Aiden Cline
b3afa84058 Revert "Added subagents to agents modal, non-selectable (#4460)"
This reverts commit 90044196bf.
2025-11-19 11:00:38 -06:00
Aiden Cline
024a10bbb5 ci: auto label nix 2025-11-19 10:51:11 -06:00
Adam
bef9ac96e2 fix(web): stats 2025-11-19 10:05:39 -06:00
Adam
24bb293136 feat(cli): better install script output 2025-11-19 09:30:41 -06:00
Adam
45180104fe fix(desktop): message animation 2025-11-19 06:04:20 -06:00
Adam
edd86e3fb7 fix(desktop): text part styling 2025-11-19 06:04:20 -06:00
Adam
4a72d57534 fix(desktop): pre styling 2025-11-19 06:04:19 -06:00
Aiden Cline
0068cb305f tweak: toast 2025-11-19 00:51:07 -06:00
opencode-agent[bot]
90044196bf Added subagents to agents modal, non-selectable (#4460)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-19 00:40:47 -06:00
Shantur Rathore
963a926db2 allow task tool to have resume capabilities (#4204)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-19 00:17:26 -06:00
Frank
0d3d48bb59 zen: fix cost in graph 2025-11-18 23:43:26 -05:00
GitHub Action
66eaba4bdc chore: format code 2025-11-19 01:02:57 +00:00
Dax Raad
21b6e5404e feat: add @opencode-ai/util package with utility functions 2025-11-18 20:02:10 -05:00
GitHub Action
a0fe59ab75 chore: format code 2025-11-18 23:49:29 +00:00
Aiden Cline
81ebf56cf1 feat: add top level lsp: false and formatter: false to allow disabling all formatters or lsps at once 2025-11-18 17:48:40 -06:00
opencode
429708e3d5 release: v1.0.78 2025-11-18 23:36:35 +00:00
shuv
d50f825c6d fix: pass model info to ReadTool to enable image support check (#4473)
Co-authored-by: GitHub Action <action@github.com>
2025-11-18 17:20:03 -06:00
K Whiteside
47bfae52c0 fix: permission checks for external_directory and doom_loop (#4433)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: AerionDyseti <AerionDyseti@users.noreply.github.com>
2025-11-18 17:18:23 -06:00
Frank
52cf9e3423 wip: zen 2025-11-18 17:42:48 -05:00
GitHub Action
a9b6debfa2 chore: format code 2025-11-18 22:24:29 +00:00
Eric Juden
d6bf475749 docs: Improving Plugin Documentation - Adding Events (#4438) 2025-11-18 16:23:46 -06:00
opencode
f22580e943 release: v1.0.77 2025-11-18 22:17:39 +00:00
Dax Raad
6d98db57c7 better gemini retry errors 2025-11-18 17:11:29 -05:00
OpeOginni
59f127a250 fix: allow for theme references (#4450) 2025-11-18 14:26:42 -06:00
Adam
3068e7dcf7 fix(desktop): animating too much 2025-11-18 14:24:26 -06:00
GitHub Action
f83d62191a chore: format code 2025-11-18 20:23:29 +00:00
Longlone
3b72857124 fix: update reasoningEffort logic for gpt-5.1 models in SessionPrompt-ensureTitle (#4456)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-18 14:22:44 -06:00
opencode
68cd105d9d release: v1.0.76 2025-11-18 20:12:38 +00:00
Aiden Cline
e09af2cb4b fix windows bash tool issue 2025-11-18 14:06:45 -06:00
Adam
14bd3b1d30 chore(desktop): remove logging 2025-11-18 13:52:29 -06:00
Adam
3a9c2152f7 fix(desktop): reactivity issue on route change 2025-11-18 13:45:27 -06:00
Frank
7283bfa480 zen: gemini 2025-11-18 14:28:31 -05:00
opencode-agent[bot]
37d5099728 Added opencode agent list command to show all available agents with details. (#4446)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-18 13:25:53 -06:00
GitHub Action
d45fc030b2 chore: format code 2025-11-18 18:35:26 +00:00
Adam
c7042c807f fix(desktop): only animate response once 2025-11-18 12:34:34 -06:00
opencode
202f6f1be9 release: v1.0.75 2025-11-18 18:16:14 +00:00
Dax Raad
759635eefa fix gpt compaction issue 2025-11-18 13:10:00 -05:00
Aiden Cline
a9981441ae tweak: use temperature 1 for gemini 3 pro 2025-11-18 11:49:39 -06:00
Adam
71302de4f1 fix(desktop): css typo 2025-11-18 11:40:50 -06:00
Adam
333b8e907b fix(desktop): busy state and reactivity 2025-11-18 11:35:23 -06:00
GitHub Action
13f319b64f chore: format code 2025-11-18 17:16:07 +00:00
opencode
b573eadd9e release: v1.0.74 2025-11-18 17:16:06 +00:00
Dax Raad
50bfff89c0 fix model dialog sorting 2025-11-18 12:10:19 -05:00
Adam
fc5fc2c570 wip(desktop): new layout work 2025-11-18 17:07:34 +00:00
Adam
4069999b78 wip(desktop): new layout work 2025-11-18 17:07:34 +00:00
opencode
5ba9b47b3c release: v1.0.73 2025-11-18 17:07:33 +00:00
Dax Raad
7c0cc94023 rework default model 2025-11-18 12:01:41 -05:00
GitHub Action
3ed1bd2e8e ignore: update download stats 2025-11-18 2025-11-18 12:04:35 +00:00
Aiden Cline
ce6436280a ci: ignore update nix hash job 2025-11-18 01:26:30 -06:00
Aiden Cline
e49204bd33 ignore: fix snapshot (#4444)
Co-authored-by: opencode <opencode@sst.dev>
2025-11-18 01:22:38 -06:00
Aiden Cline
856c87d05c fix: snapshot? 2025-11-18 01:02:52 -06:00
Aiden Cline
de35c3fb84 ci: ignore 2025-11-18 00:53:08 -06:00
Aiden Cline
4359719f9a ignore: format 2025-11-18 00:49:17 -06:00
Albert O'Shea
5e13527416 feat: nix support for the nix folks (#3924)
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2025-11-18 00:46:49 -06:00
Frank
aba94c658f wip: zen 2025-11-18 01:27:31 -05:00
opencode-agent[bot]
6e318ba567 Added width constraints to toast component for proper text wrapping. (#4441)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-18 00:23:36 -06:00
GitHub Action
ddddecf88a chore: format code 2025-11-18 05:46:02 +00:00
Frank
16cb77c094 zen: add usage graph 2025-11-18 00:45:14 -05:00
Jake Nelson
a5564f730e feat: add Swift syntax highlighting support (#4434) 2025-11-17 21:53:03 -06:00
GitHub Action
a15c97bbfe chore: format code 2025-11-18 03:19:47 +00:00
Aiden Cline
a398eed8b8 Revert "Updated scroll_speed to allow any positive number" (#4437) 2025-11-17 21:19:06 -06:00
opencode-agent[bot]
a10fd8ca5c Updated scroll_speed to allow any positive number (#4436)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2025-11-17 21:18:33 -06:00
opencode
ff7513238b release: v1.0.72 2025-11-18 03:03:54 +00:00
GitHub Action
af1cd60d3e chore: format code 2025-11-18 02:53:13 +00:00
Aiden Cline
c66def2049 fix: noreply 2025-11-17 20:52:25 -06:00
opencode
008ccb4729 release: v1.0.71 2025-11-18 01:59:40 +00:00
Dax Raad
bc232045a1 respect server suggestion for default model 2025-11-17 20:53:48 -05:00
GitHub Action
16cab556df chore: format code 2025-11-18 01:27:22 +00:00
Jay V
66148df74b docs: clarify custom tools can execute scripts in any language with Python example 2025-11-17 20:26:27 -05:00
opencode
4611e08f09 release: v1.0.70 2025-11-17 23:45:49 +00:00
Sebastian Herrlinger
bf6204f577 upgrade opentui to v0.1.46
- enable bracketed paste (and more) on win
- fix word wrapping with CJK and at wrap/chunk boundaries
- old style meta+arrow
- allow <1 scroll speed for slowdown
2025-11-18 00:22:21 +01:00
Daniel Hofheinz
17cde9feb7 docs: add built-in agents reference to README (#3047)
Co-authored-by: Jay V <air@live.ca>
2025-11-17 17:19:14 -05:00
Aiden Cline
7eccbdc4ac fix /exit 2025-11-17 16:13:41 -06:00
Aiden Cline
ab072290fc Revert "fix: system theme background to use 'none' for terminal transparency (#4408)"
This reverts commit f4a4514a9f.
2025-11-17 16:03:27 -06:00
GitHub Action
ad9d83748c chore: format code 2025-11-17 21:46:10 +00:00
Aiden Cline
55b57e1aae ci: tweak 2025-11-17 15:45:23 -06:00
Aiden Cline
21b7877beb docs: tweak wording 2025-11-17 21:26:46 +00:00
opencode
de50234a1a release: v1.0.69 2025-11-17 21:26:46 +00:00
opencode-agent[bot]
d60102ba52 Added /thinking slash command to toggle thinking blocks visibility in OpenTUI. (#4424)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-17 15:16:35 -06:00
Haris Gušić
066a876f3d docs(contributing): Add "Setting up a Debugger" section (#4421)
Co-authored-by: GitHub Action <action@github.com>
2025-11-17 14:28:06 -06:00
Haris Gušić
c07a241ca8 chore: Remove obsolete 'any' type annotation (#4423) 2025-11-17 14:27:43 -06:00
Aiden Cline
0a2fffa9b5 tweak: whitelist 2025-11-17 13:18:13 -06:00
Dax Raad
bdfa213ccf deprecated session.idle event 2025-11-17 11:42:45 -05:00
Aiden Cline
7f0b2ce1ac Reapply "fix: system theme background to use 'none' for terminal transparency" (#4415)
This reverts commit a5365ce294.
2025-11-17 10:39:53 -06:00
Dax Raad
0a2d7af179 core: honor retry-after values exceeding 10 minutes instead of discarding them 2025-11-17 11:33:28 -05:00
Dax Raad
37652f48fb ignore 2025-11-17 11:32:07 -05:00
Dax Raad
8b19c6c7e4 better retry display 2025-11-17 11:31:10 -05:00
Aiden Cline
a5365ce294 Revert "fix: system theme background to use 'none' for terminal transparency" (#4415) 2025-11-17 10:24:20 -06:00
Jensen
f4a4514a9f fix: system theme background to use 'none' for terminal transparency (#4408) 2025-11-17 10:22:31 -06:00
opencode-agent[bot]
154006469c Updated help dialog to use dynamic keybind (#4414)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-17 10:18:59 -06:00
Dax
a1214fff2e Refactor agent loop (#4412) 2025-11-17 10:57:18 -05:00
GitHub Action
9fd43ec616 ignore: update download stats 2025-11-17 2025-11-17 12:04:41 +00:00
Luke Parker
5731c268b6 fix: Line count on win (#4401) 2025-11-17 01:08:22 -06:00
Keath Milligan
f4d892d4e1 fix: handle Git Bash path mapping on windows (#4380) 2025-11-17 01:06:44 -06:00
Aiden Cline
10b3702938 chore: update type 2025-11-17 00:07:23 -06:00
Tyler Gannon
e96442310c chore: replace z.union with z.enum for cleaner OpenAPI generation (#4394) 2025-11-17 00:06:40 -06:00
Spoon
5c722bf8c4 fix(batch): simple UX feedback (#4396) 2025-11-17 00:02:05 -06:00
Youssef Achy
58cc5cdf2a add support for azure cognitive services provider (#4397) 2025-11-17 00:01:45 -06:00
opencode-agent[bot]
3c6dcad2af Fixed OPENCODE_CONFIG_DIR to load config files. (#4400)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-16 23:48:36 -06:00
Jay
2535f9febf docs: Add clarification for projects using 'opencode' name
Added a section to clarify the affiliation of related projects.
2025-11-16 20:51:41 -05:00
Aiden Cline
25678fa504 fix: vercel gateway options 2025-11-16 18:39:31 -06:00
Sebastian Herrlinger
d7f4f3ec1f bump opentui version to 0.1.45, fixing highlighting on windows 2025-11-16 23:56:11 +01:00
Aiden Cline
16ccb39459 docs: permissions 2025-11-16 16:40:48 -06:00
Aiden Cline
f8630fb188 ignore: rm 2025-11-16 16:32:04 -06:00
Baptiste Cavallo
72e604744d fix(batch): restore per-tool UI feedback + UX improvements (#4387) 2025-11-16 16:31:41 -06:00
opencode-agent[bot]
832be6e7eb Added copy option to message context menu (#4389)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2025-11-16 15:35:05 -06:00
1387 changed files with 16092 additions and 4487 deletions

View File

@@ -1,3 +1,7 @@
#
# This file is intentionally in the wrong dir, will move and add later....
#
name: Guidelines Check
on:

View File

@@ -28,14 +28,14 @@ jobs:
const versionPattern = /[v]?1\.0\./i;
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
// Check for "nix" keyword
const nixPattern = /\bnix\b/i;
const isNixRelated = nixPattern.test(title) || nixPattern.test(description);
const labels = [];
if (isWebRelated) {
// Add web label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['web']
});
labels.push('web');
// Assign to adamdotdevin
await github.rest.issues.addAssignees({
@@ -46,10 +46,18 @@ jobs:
});
} else if (isVersionRelated) {
// Only add opentui if NOT web-related
labels.push('opentui');
}
if (isNixRelated) {
labels.push('nix');
}
if (labels.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['opentui']
labels: labels
});
}

View File

@@ -27,12 +27,12 @@ jobs:
{
"bash": {
"gh issue*": "allow",
"*": "deny"
},
"*": "deny"
},
"webfetch": "deny"
}
run: |
opencode run -m anthropic/claude-sonnet-4-20250514 "A new issue has been created:'
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:'
Issue number:
${{ github.event.issue.number }}

View File

@@ -61,6 +61,13 @@ jobs:
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish
run: |
./script/publish.ts

View File

@@ -4,7 +4,7 @@ on:
push:
branches:
- dev
- fix-build
- fix-snapshot-2
- v0
concurrency: ${{ github.workflow }}-${{ github.ref }}

View File

@@ -28,3 +28,9 @@ jobs:
bun turbo test
env:
CI: true
- name: Check SDK is up to date
run: |
bun ./packages/sdk/js/script/build.ts
git diff --exit-code packages/sdk/js/src/gen packages/sdk/js/dist
continue-on-error: false

84
.github/workflows/update-nix-hashes.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
name: Update Nix Hashes
permissions:
contents: write
on:
workflow_dispatch:
push:
paths:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
pull_request:
paths:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
jobs:
update:
runs-on: ubuntu-latest
env:
SYSTEM: x86_64-linux
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Nix
uses: DeterminateSystems/nix-installer-action@v20
- name: Configure git
run: |
git config --global user.email "action@github.com"
git config --global user.name "Github Action"
- name: Update flake.lock
run: |
set -euo pipefail
nix flake update
- name: Update node_modules hash
run: |
set -euo pipefail
nix/scripts/update-hashes.sh
- name: Commit hash changes
env:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
set -euo pipefail
summarize() {
local status="$1"
{
echo "### Nix Hash Update"
echo ""
echo "- ref: ${GITHUB_REF_NAME}"
echo "- status: ${status}"
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
}
FILES=(flake.lock flake.nix nix/node-modules.nix nix/hashes.json)
STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then
summarize "no changes"
echo "No changes to tracked Nix files. Hashes are already up to date."
exit 0
fi
git add "${FILES[@]}"
git commit -m "Update Nix flake.lock and hashes"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git push origin HEAD:"$BRANCH"
summarize "committed $(git rev-parse --short HEAD)"

5
.gitignore vendored
View File

@@ -13,3 +13,8 @@ dist
.turbo
**/.serena
.serena/
/result
refs
Session.vim
opencode.json
a.out

View File

@@ -1,5 +1,6 @@
---
description: Git commit and push
subtask: true
---
commit and push

View File

@@ -0,0 +1,23 @@
---
description: "Find issue(s) on github"
model: opencode/claude-haiku-4-5
---
Search through existing issues in sst/opencode using the gh cli to find issues matching this query:
$ARGUMENTS
Consider:
1. Similar titles or descriptions
2. Same error messages or symptoms
3. Related functionality or components
4. Similar feature requests
Please list any matching issues with:
- Issue number and title
- Brief explanation of why it matches the query
- Link to the issue
If no clear matches are found, say so.

View File

@@ -1,4 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"]
}

14
.opencode/opencode.jsonc Normal file
View File

@@ -0,0 +1,14 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "http://localhost:3000",
// },
"provider": {
"opencode": {
"options": {
// "baseURL": "http://localhost:8080",
},
},
},
}

11
.vscode/launch.example.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "bun",
"request": "attach",
"name": "opencode (attach)",
"url": "ws://localhost:6499/"
}
]
}

5
.vscode/settings.example.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"oven.bun-vscode"
]
}

View File

@@ -42,6 +42,38 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
> [!NOTE]
> After touching `packages/opencode/src/server/server.ts`, run "./packages/sdk/js/script/build.ts" to regenerate the JS sdk.
### Setting up a Debugger
Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points.
The most reliable way to debug OpenCode is to run it manually in a terminal via `bun run --inspect=<url> dev ...` and attach
your debugger via that URL. Other methods can result in breakpoints being mapped incorrectly, at least in VSCode (YMMV).
Caveats:
- `*.tsx` files won't have their breakpoints correctly mapped. This seems due to Bun currently not supporting source maps on code transformed
via `BunPlugin`s (currently necessary due to our dependency on `@opentui/solid`). Currently, the best you can do in terms of debugging `*.tsx`
files is writing a `debugger;` statement. Debugging facilities like stepping won't work, but at least you will be informed if a specific code
is triggered.
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
Other tips and tricks:
- You might want to use `--inspect-wait` or `--inspect-brk` instead of `--inspect`, depending on your workflow
- Specifying `--inspect=ws://localhost:6499/` on every invocation can be tiresome, you may want to `export BUN_OPTIONS=--inspect=ws://localhost:6499/` instead
#### VSCode Setup
If you use VSCode, you can use our example configurations [.vscode/settings.example.json](.vscode/settings.example.json) and [.vscode/launch.example.json](.vscode/launch.example.json).
Some debug methods that can be problematic:
- Debug configurations with `"request": "launch"` can have breakpoints incorrectly mapped and thus unusable
- The same problem arises when running OpenCode in the VSCode `JavaScript Debug Terminal`
With that said, you may want to try these methods, as they might work for you.
## Pull Request Expectations
- Try to keep pull requests small and focused.

View File

@@ -28,9 +28,10 @@ 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 opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use --pin -g ubi:sst/opencode # Any OS
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
```
> [!TIP]
@@ -51,6 +52,22 @@ OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bas
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.
- **build** - Default, full access agent for development work
- **plan** - Read-only agent for analysis and code exploration
- Denies file edits by default
- Asks permission before running bash commands
- Ideal for exploring unfamiliar codebases or planning changes
Also, included is a **general** subagent for complex searches and multi-step tasks.
This is used internally and can be invoked using `@general` in messages.
Learn more about [agents](https://opencode.ai/docs/agents).
### Documentation
For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs).
@@ -59,6 +76,10 @@ For more info on how to configure OpenCode [**head over to our docs**](https://o
If you're interested in contributing to OpenCode, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request.
### Building on OpenCode
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in anyway.
### FAQ
#### How is this different than Claude Code?
@@ -66,7 +87,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source
- Not coupled to any provider. Although Anthropic is recommended, OpenCode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
- Out of the box LSP support
- 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.

View File

@@ -142,3 +142,8 @@
| 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) |

641
bun.lock

File diff suppressed because it is too large Load Diff

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1763618868,
"narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

107
flake.nix Normal file
View File

@@ -0,0 +1,107 @@
{
description = "OpenCode development flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs =
{
nixpkgs,
...
}:
let
systems = [
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
lib = nixpkgs.lib;
forEachSystem = lib.genAttrs systems;
pkgsFor = system: nixpkgs.legacyPackages.${system};
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
bunTarget = {
"aarch64-linux" = "bun-linux-arm64";
"x86_64-linux" = "bun-linux-x64";
"aarch64-darwin" = "bun-darwin-arm64";
"x86_64-darwin" = "bun-darwin-x64";
};
defaultNodeModules = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
hashesFile = "${./nix}/hashes.json";
hashesData =
if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { };
nodeModulesHash = hashesData.nodeModules or defaultNodeModules;
modelsDev = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
pkgs."models-dev"
);
in
{
devShells = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = with pkgs; [
bun
nodejs_20
pkg-config
openssl
git
];
};
}
);
packages = forEachSystem (
system:
let
pkgs = pkgsFor system;
mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
hash = nodeModulesHash;
};
mkPackage = pkgs.callPackage ./nix/opencode.nix { };
in
{
default = mkPackage {
version = packageJson.version;
src = ./.;
scripts = ./nix/scripts;
target = bunTarget.${system};
modelsDev = "${modelsDev.${system}}/dist/_api.json";
mkNodeModules = mkNodeModules;
};
}
);
apps = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
{
opencode-dev = {
type = "app";
meta = {
description = "Nix devshell shell for OpenCode";
runtimeInputs = [ pkgs.bun ];
};
program = "${
pkgs.writeShellApplication {
name = "opencode-dev";
text = ''
exec bun run dev "$@"
'';
}
}/bin/opencode-dev";
};
}
);
};
}

View File

@@ -97,8 +97,12 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
],
})
const ZEN_MODELS1 = new sst.Secret("ZEN_MODELS1")
const ZEN_MODELS2 = new sst.Secret("ZEN_MODELS2")
const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"),
new sst.Secret("ZEN_MODELS2"),
new sst.Secret("ZEN_MODELS3"),
new sst.Secret("ZEN_MODELS4"),
]
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!) },
@@ -132,11 +136,10 @@ new sst.cloudflare.x.SolidStart("Console", {
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
STRIPE_SECRET_KEY,
ZEN_MODELS1,
ZEN_MODELS2,
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
...ZEN_MODELS,
...($dev
? [
new sst.Secret("CLOUDFLARE_DEFAULT_ACCOUNT_ID", process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID!),

154
install
View File

@@ -2,9 +2,8 @@
set -euo pipefail
APP=opencode
MUTED='\033[0;2m'
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
ORANGE='\033[38;2;255;140;0m'
NC='\033[0m' # No Color
@@ -26,7 +25,11 @@ elif [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
filename="$APP-$os-$arch.zip"
if [ "$os" = "linux" ]; then
filename="$APP-$os-$arch.tar.gz"
else
filename="$APP-$os-$arch.zip"
fi
case "$filename" in
@@ -45,6 +48,18 @@ case "$filename" in
;;
esac
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"
@@ -67,8 +82,8 @@ print_message() {
local color=""
case $level in
info) color="${GREEN}" ;;
warning) color="${YELLOW}" ;;
info) color="${NC}" ;;
warning) color="${NC}" ;;
error) color="${RED}" ;;
esac
@@ -86,19 +101,119 @@ check_version() {
installed_version=$(echo $installed_version | awk '{print $2}')
if [[ "$installed_version" != "$specific_version" ]]; then
print_message info "Installed version: ${YELLOW}$installed_version."
print_message info "${MUTED}Installed version: ${NC}$installed_version."
else
print_message info "Version ${YELLOW}$specific_version${GREEN} already installed"
print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
exit 0
fi
fi
}
unbuffered_sed() {
if echo | sed -u -e "" >/dev/null 2>&1; then
sed -nu "$@"
elif echo | sed -l -e "" >/dev/null 2>&1; then
sed -nl "$@"
else
local pad="$(printf "\n%512s" "")"
sed -ne "s/$/\\${pad}/" "$@"
fi
}
print_progress() {
local bytes="$1"
local length="$2"
[ "$length" -gt 0 ] || return 0
local width=50
local percent=$(( bytes * 100 / length ))
[ "$percent" -gt 100 ] && percent=100
local on=$(( percent * width / 100 ))
local off=$(( width - on ))
local filled=$(printf "%*s" "$on" "")
filled=${filled// /■}
local empty=$(printf "%*s" "$off" "")
empty=${empty// /・}
printf "\r${ORANGE}%s%s %3d%%${NC}" "$filled" "$empty" "$percent" >&4
}
download_with_progress() {
local url="$1"
local output="$2"
if [ -t 2 ]; then
exec 4>&2
else
exec 4>/dev/null
fi
local tmp_dir=${TMPDIR:-/tmp}
local basename="${tmp_dir}/opencode_install_$$"
local tracefile="${basename}.trace"
rm -f "$tracefile"
mkfifo "$tracefile"
# Hide cursor
printf "\033[?25l" >&4
trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
(
curl --trace-ascii "$tracefile" -s -L -o "$output" "$url"
) &
local curl_pid=$!
unbuffered_sed \
-e 'y/ACDEGHLNORTV/acdeghlnortv/' \
-e '/^0000: content-length:/p' \
-e '/^<= recv data/p' \
"$tracefile" | \
{
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')
bytes=0
elif [ "$tag" = "<= recv" ]; then
local size="${line[3]}"
bytes=$(( bytes + size ))
if [ "$length" -gt 0 ]; then
print_progress "$bytes" "$length"
fi
fi
done
}
wait $curl_pid
local ret=$?
echo "" >&4
return $ret
}
download_and_install() {
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
mkdir -p opencodetmp && cd opencodetmp
curl -# -L -o "$filename" "$url"
unzip -q "$filename"
if [[ "$os" == "windows" ]] || ! download_with_progress "$url" "$filename"; then
# Fallback to standard curl on Windows or if custom progress fails
curl -# -L -o "$filename" "$url"
fi
if [ "$os" = "linux" ]; then
tar -xzf "$filename"
else
unzip -q "$filename"
fi
mv opencode "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode"
cd .. && rm -rf opencodetmp
@@ -117,7 +232,7 @@ add_to_path() {
elif [[ -w $config_file ]]; then
echo -e "\n# opencode" >> "$config_file"
echo "$command" >> "$config_file"
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
print_message info "${MUTED}Successfully added ${NC}opencode ${MUTED}to \$PATH in ${NC}$config_file"
else
print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " $command"
@@ -191,3 +306,20 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
echo "$INSTALL_DIR" >> $GITHUB_PATH
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
fi
echo -e ""
echo -e "${MUTED}  ${NC} ▄ "
echo -e "${MUTED}█▀▀█ █▀▀█ █▀▀█ █▀▀▄ ${NC}█▀▀▀ █▀▀█ █▀▀█ █▀▀█"
echo -e "${MUTED}█░░█ █░░█ █▀▀▀ █░░█ ${NC}█░░░ █░░█ █░░█ █▀▀▀"
echo -e "${MUTED}▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ${NC}▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"
echo -e ""
echo -e ""
echo -e "${MUTED}To get started, navigate to a project and run:${NC}"
echo -e "opencode ${MUTED}Use free models${NC}"
echo -e "opencode auth login ${MUTED}Add paid provider API keys${NC}"
echo -e "opencode help ${MUTED}List commands and options${NC}"
echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
echo -e ""
echo -e ""

3
nix/hashes.json Normal file
View File

@@ -0,0 +1,3 @@
{
"nodeModules": "sha256-HILcNy8B0k++ObGwUrD9dM0n9MvASR4AiyxmKSv3LV8="
}

52
nix/node-modules.nix Normal file
View File

@@ -0,0 +1,52 @@
{ hash, lib, stdenvNoCC, bun, cacert, curl }:
args:
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = args.version;
src = args.src;
impureEnvVars =
lib.fetchers.proxyImpureEnvVars
++ [
"GIT_PROXY_COMMAND"
"SOCKS_SERVER"
];
nativeBuildInputs = [ bun cacert curl ];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="*" \
--os="*" \
--frozen-lockfile \
--ignore-scripts \
--no-progress \
--linker=isolated
bun --bun ${args.canonicalizeScript}
bun --bun ${args.normalizeBinsScript}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
while IFS= read -r dir; do
rel="''${dir#./}"
dest="$out/$rel"
mkdir -p "$(dirname "$dest")"
cp -R "$dir" "$dest"
done < <(find . -type d -name node_modules -prune | sort)
runHook postInstall
'';
dontFixup = true;
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = hash;
}

108
nix/opencode.nix Normal file
View File

@@ -0,0 +1,108 @@
{ lib, stdenv, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
args:
let
scripts = args.scripts;
mkModules =
attrs:
args.mkNodeModules (
attrs
// {
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
}
);
in
stdenvNoCC.mkDerivation (finalAttrs: {
pname = "opencode";
version = args.version;
src = args.src;
node_modules = mkModules {
version = finalAttrs.version;
src = finalAttrs.src;
};
nativeBuildInputs = [
bun
makeBinaryWrapper
];
configurePhase = ''
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
runHook postConfigure
'';
env.MODELS_DEV_API_JSON = args.modelsDev;
env.OPENCODE_VERSION = args.version;
env.OPENCODE_CHANNEL = "stable";
buildPhase = ''
runHook preBuild
cp ${scripts + "/bun-build.ts"} bun-build.ts
substituteInPlace bun-build.ts \
--replace '@VERSION@' "${finalAttrs.version}"
export BUN_COMPILE_TARGET=${args.target}
bun --bun bun-build.ts
runHook postBuild
'';
dontStrip = true;
installPhase = ''
runHook preInstall
cd packages/opencode
if [ ! -f opencode ]; then
echo "ERROR: opencode binary not found in $(pwd)"
ls -la
exit 1
fi
if [ ! -f opencode-worker.js ]; then
echo "ERROR: opencode worker bundle not found in $(pwd)"
ls -la
exit 1
fi
install -Dm755 opencode $out/bin/opencode
install -Dm644 opencode-worker.js $out/bin/opencode-worker.js
if [ -f opencode-assets.manifest ]; then
while IFS= read -r asset; do
[ -z "$asset" ] && continue
if [ ! -f "$asset" ]; then
echo "ERROR: referenced asset \"$asset\" missing"
exit 1
fi
install -Dm644 "$asset" "$out/bin/$(basename "$asset")"
done < opencode-assets.manifest
fi
runHook postInstall
'';
postFixup = ''
wrapProgram "$out/bin/opencode" --prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]}
'';
meta = {
description = "AI coding agent built for the terminal";
longDescription = ''
OpenCode is a terminal-based agent that can build anything.
It combines a TypeScript/JavaScript core with a Go-based TUI
to provide an interactive AI coding experience.
'';
homepage = "https://github.com/sst/opencode";
license = lib.licenses.mit;
platforms = [
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
mainProgram = "opencode";
};
})

115
nix/scripts/bun-build.ts Normal file
View File

@@ -0,0 +1,115 @@
import solidPlugin from "./packages/opencode/node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
import fs from "fs"
const version = "@VERSION@"
const pkg = path.join(process.cwd(), "packages/opencode")
const parser = fs.realpathSync(path.join(pkg, "./node_modules/@opentui/core/parser.worker.js"))
const worker = "./src/cli/cmd/tui/worker.ts"
const target = process.env["BUN_COMPILE_TARGET"]
if (!target) {
throw new Error("BUN_COMPILE_TARGET not set")
}
process.chdir(pkg)
const manifestName = "opencode-assets.manifest"
const manifestPath = path.join(pkg, manifestName)
const readTrackedAssets = () => {
if (!fs.existsSync(manifestPath)) return []
return fs
.readFileSync(manifestPath, "utf8")
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
}
const removeTrackedAssets = () => {
for (const file of readTrackedAssets()) {
const filePath = path.join(pkg, file)
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true })
}
}
}
const assets = new Set<string>()
const addAsset = async (p: string) => {
const file = path.basename(p)
const dest = path.join(pkg, file)
await Bun.write(dest, Bun.file(p))
assets.add(file)
}
removeTrackedAssets()
const result = await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
sourcemap: "external",
entrypoints: ["./src/index.ts", parser, worker],
define: {
OPENCODE_VERSION: `'@VERSION@'`,
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(pkg, parser).replace(/\\/g, "/"),
OPENCODE_CHANNEL: "'latest'",
},
compile: {
target,
outfile: "opencode",
execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"],
windows: {},
},
})
if (!result.success) {
console.error("Build failed!")
for (const log of result.logs) {
console.error(log)
}
throw new Error("Compilation failed")
}
const assetOutputs = result.outputs?.filter((x) => x.kind === "asset") ?? []
for (const x of assetOutputs) {
await addAsset(x.path)
}
const bundle = await Bun.build({
entrypoints: [worker],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
target: "bun",
outdir: "./.opencode-worker",
sourcemap: "none",
})
if (!bundle.success) {
console.error("Worker build failed!")
for (const log of bundle.logs) {
console.error(log)
}
throw new Error("Worker compilation failed")
}
const workerAssets = bundle.outputs?.filter((x) => x.kind === "asset") ?? []
for (const x of workerAssets) {
await addAsset(x.path)
}
const output = bundle.outputs.find((x) => x.kind === "entry-point")
if (!output) {
throw new Error("Worker build produced no entry-point output")
}
const dest = path.join(pkg, "opencode-worker.js")
await Bun.write(dest, Bun.file(output.path))
fs.rmSync(path.dirname(output.path), { recursive: true, force: true })
const list = Array.from(assets)
await Bun.write(manifestPath, list.length > 0 ? list.join("\n") + "\n" : "")
console.log("Build successful!")

View File

@@ -0,0 +1,96 @@
import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
import { join, relative } from "path"
type SemverLike = {
valid: (value: string) => string | null
rcompare: (left: string, right: string) => number
}
type Entry = {
dir: string
version: string
label: string
}
const root = process.cwd()
const bunRoot = join(root, "node_modules/.bun")
const linkRoot = join(bunRoot, "node_modules")
const directories = (await readdir(bunRoot)).sort()
const versions = new Map<string, Entry[]>()
for (const entry of directories) {
const full = join(bunRoot, entry)
const info = await lstat(full)
if (!info.isDirectory()) {
continue
}
const marker = entry.lastIndexOf("@")
if (marker <= 0) {
continue
}
const slug = entry.slice(0, marker).replace(/\+/g, "/")
const version = entry.slice(marker + 1)
const list = versions.get(slug) ?? []
list.push({ dir: full, version, label: entry })
versions.set(slug, list)
}
const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
| SemverLike
| {
default: SemverLike
}
const semver = "default" in semverModule ? semverModule.default : semverModule
const selections = new Map<string, Entry>()
for (const [slug, list] of versions) {
list.sort((a, b) => {
const left = semver.valid(a.version)
const right = semver.valid(b.version)
if (left && right) {
const delta = semver.rcompare(left, right)
if (delta !== 0) {
return delta
}
}
if (left && !right) {
return -1
}
if (!left && right) {
return 1
}
return b.version.localeCompare(a.version)
})
selections.set(slug, list[0])
}
await rm(linkRoot, { recursive: true, force: true })
await mkdir(linkRoot, { recursive: true })
const rewrites: string[] = []
for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
const parts = slug.split("/")
const leaf = parts.pop()
if (!leaf) {
continue
}
const parent = join(linkRoot, ...parts)
await mkdir(parent, { recursive: true })
const linkPath = join(parent, leaf)
const desired = join(entry.dir, "node_modules", slug)
const relativeTarget = relative(parent, desired)
const resolved = relativeTarget.length === 0 ? "." : relativeTarget
await rm(linkPath, { recursive: true, force: true })
await symlink(resolved, linkPath)
rewrites.push(slug + " -> " + resolved)
}
rewrites.sort()
console.log("[canonicalize-node-modules] rebuilt", rewrites.length, "links")
for (const line of rewrites.slice(0, 20)) {
console.log(" ", line)
}
if (rewrites.length > 20) {
console.log(" ...")
}

View File

@@ -0,0 +1,138 @@
import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
import { join, relative } from "path"
type PackageManifest = {
name?: string
bin?: string | Record<string, string>
}
const root = process.cwd()
const bunRoot = join(root, "node_modules/.bun")
const bunEntries = (await safeReadDir(bunRoot)).sort()
let rewritten = 0
for (const entry of bunEntries) {
const modulesRoot = join(bunRoot, entry, "node_modules")
if (!(await exists(modulesRoot))) {
continue
}
const binRoot = join(modulesRoot, ".bin")
await rm(binRoot, { recursive: true, force: true })
await mkdir(binRoot, { recursive: true })
const packageDirs = await collectPackages(modulesRoot)
for (const packageDir of packageDirs) {
const manifest = await readManifest(packageDir)
if (!manifest) {
continue
}
const binField = manifest.bin
if (!binField) {
continue
}
const seen = new Set<string>()
if (typeof binField === "string") {
const fallback = manifest.name ?? packageDir.split("/").pop()
if (fallback) {
await linkBinary(binRoot, fallback, packageDir, binField, seen)
}
} else {
const entries = Object.entries(binField).sort((a, b) => a[0].localeCompare(b[0]))
for (const [name, target] of entries) {
await linkBinary(binRoot, name, packageDir, target, seen)
}
}
}
}
console.log(`[normalize-bun-binaries] rewrote ${rewritten} links`)
async function collectPackages(modulesRoot: string) {
const found: string[] = []
const topLevel = (await safeReadDir(modulesRoot)).sort()
for (const name of topLevel) {
if (name === ".bin" || name === ".bun") {
continue
}
const full = join(modulesRoot, name)
if (!(await isDirectory(full))) {
continue
}
if (name.startsWith("@")) {
const scoped = (await safeReadDir(full)).sort()
for (const child of scoped) {
const scopedDir = join(full, child)
if (await isDirectory(scopedDir)) {
found.push(scopedDir)
}
}
continue
}
found.push(full)
}
return found.sort()
}
async function readManifest(dir: string) {
const file = Bun.file(join(dir, "package.json"))
if (!(await file.exists())) {
return null
}
const data = (await file.json()) as PackageManifest
return data
}
async function linkBinary(binRoot: string, name: string, packageDir: string, target: string, seen: Set<string>) {
if (!name || !target) {
return
}
const normalizedName = normalizeBinName(name)
if (seen.has(normalizedName)) {
return
}
const resolved = join(packageDir, target)
const script = Bun.file(resolved)
if (!(await script.exists())) {
return
}
seen.add(normalizedName)
const destination = join(binRoot, normalizedName)
const relativeTarget = relative(binRoot, resolved) || "."
await rm(destination, { force: true })
await symlink(relativeTarget, destination)
rewritten++
}
async function exists(path: string) {
try {
await lstat(path)
return true
} catch {
return false
}
}
async function isDirectory(path: string) {
try {
const info = await lstat(path)
return info.isDirectory()
} catch {
return false
}
}
async function safeReadDir(path: string) {
try {
return await readdir(path)
} catch {
return []
}
}
function normalizeBinName(name: string) {
const slash = name.lastIndexOf("/")
if (slash >= 0) {
return name.slice(slash + 1)
}
return name
}

112
nix/scripts/update-hashes.sh Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
SYSTEM=${SYSTEM:-x86_64-linux}
DEFAULT_HASH_FILE=${MODULES_HASH_FILE:-nix/hashes.json}
HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE}
if [ ! -f "$HASH_FILE" ]; then
cat >"$HASH_FILE" <<EOF
{
"nodeModules": "$DUMMY"
}
EOF
fi
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if ! git ls-files --error-unmatch "$HASH_FILE" >/dev/null 2>&1; then
git add -N "$HASH_FILE" >/dev/null 2>&1 || true
fi
fi
export DUMMY
export NIX_KEEP_OUTPUTS=1
export NIX_KEEP_DERIVATIONS=1
cleanup() {
rm -f "${JSON_OUTPUT:-}" "${BUILD_LOG:-}" "${TMP_EXPR:-}"
}
trap cleanup EXIT
write_node_modules_hash() {
local value="$1"
local temp
temp=$(mktemp)
jq --arg value "$value" '.nodeModules = $value' "$HASH_FILE" >"$temp"
mv "$temp" "$HASH_FILE"
}
TARGET="packages.${SYSTEM}.default"
MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules"
CORRECT_HASH=""
DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")"
echo "Setting dummy node_modules outputHash for ${SYSTEM}..."
write_node_modules_hash "$DUMMY"
BUILD_LOG=$(mktemp)
JSON_OUTPUT=$(mktemp)
echo "Building node_modules for ${SYSTEM} to discover correct outputHash..."
echo "Attempting to realize derivation: ${DRV_PATH}"
REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true)
BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true)
if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then
echo "Realized node_modules output: $BUILD_PATH"
CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true)
fi
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
fi
if [ -z "$CORRECT_HASH" ]; then
echo "Searching for kept failed build directory..."
KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1)
if [ -z "$KEPT_DIR" ]; then
KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1)
fi
if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then
echo "Found kept build directory: $KEPT_DIR"
if [ -d "$KEPT_DIR/build" ]; then
HASH_PATH="$KEPT_DIR/build"
else
HASH_PATH="$KEPT_DIR"
fi
echo "Attempting to hash: $HASH_PATH"
ls -la "$HASH_PATH" || true
if [ -d "$HASH_PATH/node_modules" ]; then
CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true)
echo "Computed hash from kept build: $CORRECT_HASH"
fi
fi
fi
fi
if [ -z "$CORRECT_HASH" ]; then
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
echo "Build log:"
cat "$BUILD_LOG"
exit 1
fi
write_node_modules_hash "$CORRECT_HASH"
jq -e --arg hash "$CORRECT_HASH" '.nodeModules == $hash' "$HASH_FILE" >/dev/null
echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH"
rm -f "$BUILD_LOG"
unset BUILD_LOG

View File

@@ -4,12 +4,13 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.2",
"packageManager": "bun@1.3.3",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'"
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'"
},
"workspaces": {
"packages": [
@@ -19,33 +20,37 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.0",
"@types/bun": "1.3.3",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@tsconfig/node22": "22.0.2",
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.4.4",
"@solidjs/meta": "0.29.4",
"@pierre/precision-diffs": "0.5.4",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.8",
"ai": "5.0.97",
"hono": "4.7.10",
"hono-openapi": "1.1.1",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251014.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"solid-js": "1.9.9",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
"vite": "7.1.4",
"vite-plugin-solid": "2.11.8"
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "1.2.0",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10"
}
},
"devDependencies": {
@@ -56,6 +61,7 @@
"turbo": "2.5.6"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*"
},

View File

@@ -1,25 +1,26 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.98",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vinxi dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "1.0.68"
"start": "vinxi start"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@jsx-email/render": "1.1.1",
"@kobalte/core": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-mail": "workspace:*",
"@openauthjs/openauth": "catalog:",
"@kobalte/core": "catalog:",
"@jsx-email/render": "1.1.1",
"@opencode-ai/console-resource": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"chart.js": "4.5.1",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
"zod": "catalog:"

View File

@@ -202,6 +202,14 @@ export function IconZai(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
export function IconGoogle(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 50 50" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M49.04,24.001l-1.082-0.043h-0.001C36.134,23.492,26.508,13.866,26.042,2.043L25.999,0.96C25.978,0.424,25.537,0,25,0 s-0.978,0.424-0.999,0.96l-0.043,1.083C23.492,13.866,13.866,23.492,2.042,23.958L0.96,24.001C0.424,24.022,0,24.463,0,25 c0,0.537,0.424,0.978,0.961,0.999l1.082,0.042c11.823,0.467,21.449,10.093,21.915,21.916l0.043,1.083C24.022,49.576,24.463,50,25,50 s0.978-0.424,0.999-0.96l0.043-1.083c0.466-11.823,10.092-21.449,21.915-21.916l1.082-0.042C49.576,25.978,50,25.537,50,25 C50,24.463,49.576,24.022,49.04,24.001z"></path>
</svg>
)
}
export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">
@@ -212,3 +220,30 @@ export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg>
)
}
export function IconChevronLeft(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 20 20" fill="none">
<path d="M12 15L7 10L12 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
</svg>
)
}
export function IconChevronRight(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 20 20" fill="none">
<path d="M8 5L13 10L8 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
</svg>
)
}
export function IconBreakdown(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 12L2 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M6 12L6 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M10 12L10 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M14 12L14 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
)
}

View File

@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "250",
commits: "3,500",
contributors: "300",
commits: "4,000",
monthlyUsers: "300,000",
},
} as const

View File

@@ -0,0 +1,145 @@
.root {
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
height: 400px;
display: flex;
align-items: center;
justify-content: center;
p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="filter-container"] {
margin-bottom: 0;
display: flex;
align-items: center;
gap: var(--space-3);
[data-component="dropdown"] {
[data-slot="trigger"] {
border: 1px solid var(--color-border);
background-color: var(--color-bg);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
&:hover {
border-color: var(--color-accent);
}
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
}
[data-slot="chevron"] {
opacity: 0.6;
}
[data-slot="dropdown"] {
min-width: 200px;
max-height: 300px;
overflow-y: auto;
padding: var(--space-1);
}
}
}
[data-slot="month-picker"] {
display: flex;
align-items: center;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: 0;
}
[data-slot="month-button"] {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none !important;
color: var(--color-text);
cursor: pointer;
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
line-height: 1;
&:hover {
background-color: var(--color-bg-hover);
}
svg {
display: block;
width: 16px;
height: 16px;
stroke-width: 2;
}
}
[data-slot="month-label"] {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
line-height: 1.5;
min-width: 140px;
text-align: center;
white-space: nowrap;
}
[data-slot="model-item"] {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition: background-color 0.2s;
font-size: var(--font-size-sm);
color: var(--color-text);
border: none !important;
background: none;
width: 100%;
text-align: left;
white-space: nowrap;
&:hover {
background: var(--color-bg-hover);
}
span {
flex: 1;
user-select: none;
}
}
[data-slot="chart-container"] {
padding: var(--space-6);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
height: 400px;
}
@media (max-width: 40rem) {
[data-slot="chart-container"] {
height: 300px;
padding: var(--space-4);
}
[data-component="empty-state"] {
height: 300px;
}
}
}

View File

@@ -0,0 +1,423 @@
import { and, Database, eq, gte, inArray, isNull, lte, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js"
import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
import { createAsync, query, useParams } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Dropdown } from "~/component/dropdown"
import { IconChevronLeft, IconChevronRight } from "~/component/icon"
import styles from "./graph-section.module.css"
import {
Chart,
BarController,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
type ChartConfiguration,
} from "chart.js"
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
async function getCosts(workspaceID: string, year: number, month: number) {
"use server"
return withActor(async () => {
const startDate = new Date(year, month, 1)
const endDate = new Date(year, month + 1, 0)
// First query: get usage data without joining keys
const usageData = await Database.use((tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`,
model: UsageTable.model,
totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID,
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspaceID),
gte(UsageTable.timeCreated, startDate),
lte(UsageTable.timeCreated, endDate),
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
.then((x) =>
x.map((r) => ({
...r,
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
})),
),
)
// Get unique key IDs from usage
const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null))
// Second query: get all existing keys plus any keys from usage
const keysData = await Database.use((tx) =>
tx
.select({
keyId: KeyTable.id,
keyName: KeyTable.name,
userEmail: AuthTable.subject,
timeDeleted: KeyTable.timeDeleted,
})
.from(KeyTable)
.innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.where(
and(
eq(KeyTable.workspaceID, workspaceID),
usageKeyIds.size > 0
? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted))
: isNull(KeyTable.timeDeleted),
),
)
.orderBy(AuthTable.subject, KeyTable.name),
)
return {
usage: usageData,
keys: keysData.map((key) => ({
id: key.keyId,
displayName:
key.timeDeleted !== null
? `${key.userEmail} - ${key.keyName} (deleted)`
: `${key.userEmail} - ${key.keyName}`,
})),
}
}, workspaceID)
}
const queryCosts = query(getCosts, "costs.get")
const MODEL_COLORS: Record<string, string> = {
"claude-sonnet-4-5": "#D4745C",
"claude-sonnet-4": "#E8B4A4",
"claude-opus-4": "#C8A098",
"claude-haiku-4-5": "#F0D8D0",
"claude-3-5-haiku": "#F8E8E0",
"gpt-5.1": "#4A90E2",
"gpt-5.1-codex": "#6BA8F0",
"gpt-5": "#7DB8F8",
"gpt-5-codex": "#9FCAFF",
"gpt-5-nano": "#B8D8FF",
"grok-code": "#8B5CF6",
"big-pickle": "#10B981",
"kimi-k2": "#F59E0B",
"qwen3-coder": "#EC4899",
"glm-4.6": "#14B8A6",
}
function getModelColor(model: string): string {
if (MODEL_COLORS[model]) return MODEL_COLORS[model]
const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0)
const hue = Math.abs(hash) % 360
return `hsl(${hue}, 50%, 65%)`
}
function formatDateLabel(dateStr: string): string {
const date = new Date()
const [y, m, d] = dateStr.split("-").map(Number)
date.setFullYear(y)
date.setMonth(m - 1)
date.setDate(d)
date.setHours(0, 0, 0, 0)
const month = date.toLocaleDateString("en-US", { month: "short" })
const day = date.getUTCDate().toString().padStart(2, "0")
return `${month} ${day}`
}
function addOpacityToColor(color: string, opacity: number): string {
if (color.startsWith("#")) {
const r = parseInt(color.slice(1, 3), 16)
const g = parseInt(color.slice(3, 5), 16)
const b = parseInt(color.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}
if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla")
return color
}
export function GraphSection() {
let canvasRef: HTMLCanvasElement | undefined
let chartInstance: Chart | undefined
const params = useParams()
const now = new Date()
const [store, setStore] = createStore({
data: null as Awaited<ReturnType<typeof getCosts>> | null,
year: now.getFullYear(),
month: now.getMonth(),
key: null as string | null,
model: null as string | null,
modelDropdownOpen: false,
keyDropdownOpen: false,
})
const initialData = createAsync(() => queryCosts(params.id!, store.year, store.month))
const onPreviousMonth = async () => {
const month = store.month === 0 ? 11 : store.month - 1
const year = store.month === 0 ? store.year - 1 : store.year
const data = await getCosts(params.id!, year, month)
setStore({ month, year, data })
}
const onNextMonth = async () => {
const month = store.month === 11 ? 0 : store.month + 1
const year = store.month === 11 ? store.year + 1 : store.year
setStore({ month, year, data: await getCosts(params.id!, year, month) })
}
const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
const getData = createMemo(() => store.data ?? initialData())
const getModels = createMemo(() => {
const data = getData()
if (!data?.usage) return []
return Array.from(new Set(data.usage.map((row) => row.model))).sort()
})
const getDates = createMemo(() => {
const daysInMonth = new Date(store.year, store.month + 1, 0).getDate()
return Array.from({ length: daysInMonth }, (_, i) => {
const date = new Date(store.year, store.month, i + 1)
return date.toISOString().split("T")[0]
})
})
const getKeyName = (keyID: string | null): string => {
if (!keyID || !store.data?.keys) return "All Keys"
const found = store.data.keys.find((k) => k.id === keyID)
return found?.displayName ?? "All Keys"
}
const formatMonthYear = () =>
new Date(store.year, store.month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
const chartConfig = createMemo((): ChartConfiguration | null => {
const data = getData()
const dates = getDates()
if (!data?.usage?.length) return null
const dailyData = new Map<string, Map<string, number>>()
for (const dateKey of dates) dailyData.set(dateKey, new Map())
data.usage
.filter((row) => (store.key ? row.keyId === store.key : true))
.forEach((row) => {
const dayMap = dailyData.get(row.date)
if (!dayMap) return
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
})
const filteredModels = store.model === null ? getModels() : [store.model]
const datasets = filteredModels.map((model) => {
const color = getModelColor(model)
return {
label: model,
data: dates.map((date) => (dailyData.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: color,
hoverBackgroundColor: color,
borderWidth: 0,
}
})
return {
type: "bar",
data: {
labels: dates.map(formatDateLabel),
datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
maxRotation: 0,
autoSkipPadding: 20,
color: "rgba(255, 255, 255, 0.5)",
font: {
family: "monospace",
size: 11,
},
},
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: "rgba(255, 255, 255, 0.1)",
},
ticks: {
color: "rgba(255, 255, 255, 0.5)",
font: {
family: "monospace",
size: 11,
},
callback: (value) => {
const num = Number(value)
return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}`
},
},
},
},
plugins: {
tooltip: {
mode: "index",
intersect: false,
backgroundColor: "rgba(0, 0, 0, 0.9)",
titleColor: "rgba(255, 255, 255, 0.9)",
bodyColor: "rgba(255, 255, 255, 0.8)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
label: (context) => {
const value = context.parsed.y
if (!value || value === 0) return
return `${context.dataset.label}: $${value.toFixed(2)}`
},
},
},
legend: {
display: true,
position: "bottom",
labels: {
color: "rgba(255, 255, 255, 0.7)",
font: {
size: 12,
},
padding: 16,
boxWidth: 16,
boxHeight: 16,
usePointStyle: false,
},
onHover: (event, legendItem, legend) => {
const chart = legend.chart
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const baseColor = getModelColor(dataset.label || "")
const color = i === legendItem.datasetIndex ? baseColor : addOpacityToColor(baseColor, 0.3)
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = color
})
})
chart.update("none")
},
onLeave: (event, legendItem, legend) => {
const chart = legend.chart
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const baseColor = getModelColor(dataset.label || "")
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = baseColor
})
})
chart.update("none")
},
},
},
},
}
})
createEffect(() => {
const config = chartConfig()
if (!config || !canvasRef) return
if (chartInstance) chartInstance.destroy()
chartInstance = new Chart(canvasRef, config)
})
onCleanup(() => chartInstance?.destroy())
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Cost</h2>
<p>Usage costs broken down by model.</p>
</div>
<Show when={getData()}>
<div data-slot="filter-container">
<div data-slot="month-picker">
<button data-slot="month-button" onClick={onPreviousMonth}>
<IconChevronLeft />
</button>
<span data-slot="month-label">{formatMonthYear()}</span>
<button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
<IconChevronRight />
</button>
</div>
<Dropdown
trigger={store.model === null ? "All Models" : store.model}
open={store.modelDropdownOpen}
onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectModel(null)}>
<span>All Models</span>
</button>
<For each={getModels()}>
{(model) => (
<button data-slot="model-item" onClick={() => onSelectModel(model)}>
<span>{model}</span>
</button>
)}
</For>
</>
</Dropdown>
<Dropdown
trigger={getKeyName(store.key)}
open={store.keyDropdownOpen}
onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
<span>All Keys</span>
</button>
<For each={getData()?.keys || []}>
{(key) => (
<button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
<span>{key.displayName}</span>
</button>
)}
</For>
</>
</Dropdown>
</div>
</Show>
<Show
when={chartConfig()}
fallback={
<div data-component="empty-state">
<p>No usage data available for the selected period.</p>
</div>
}
>
<div data-slot="chart-container">
<canvas ref={canvasRef} />
</div>
</Show>
</section>
)
}

View File

@@ -5,6 +5,7 @@ import { NewUserSection } from "./new-user-section"
import { UsageSection } from "./usage-section"
import { ModelSection } from "./model-section"
import { ProviderSection } from "./provider-section"
import { GraphSection } from "./graph-section"
import { IconLogo } from "~/component/icon"
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
@@ -66,6 +67,9 @@ export default function () {
<div data-slot="sections">
<NewUserSection />
<Show when={userInfo()?.isAdmin}>
<GraphSection />
</Show>
<ModelSection />
<Show when={userInfo()?.isAdmin}>
<ProviderSection />

View File

@@ -5,11 +5,21 @@ import { withActor } from "~/context/auth.withActor"
import { ZenData } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css"
import { querySessionInfo } from "../common"
import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon"
import {
IconAlibaba,
IconAnthropic,
IconGoogle,
IconMoonshotAI,
IconOpenAI,
IconStealth,
IconXai,
IconZai,
} from "~/component/icon"
const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("gemini")) return "Google"
if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba"
@@ -24,7 +34,17 @@ const getModelsInfo = query(async (workspaceID: string) => {
all: Object.entries(ZenData.list().models)
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
.filter(([id, _model]) => !id.startsWith("alpha-"))
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
.sort(([idA, modelA], [idB, modelB]) => {
const priority = ["big-pickle", "grok", "claude", "gpt", "gemini"]
const getPriority = (id: string) => {
const index = priority.findIndex((p) => id.startsWith(p))
return index === -1 ? Infinity : index
}
const pA = getPriority(idA)
const pB = getPriority(idB)
if (pA !== pB) return pA - pB
return modelA.name.localeCompare(modelB.name)
})
.map(([id, model]) => ({ id, name: model.name })),
disabled: await Model.listDisabled(),
}
@@ -96,6 +116,8 @@ export function ModelSection() {
return <IconOpenAI width={16} height={16} />
case "Anthropic":
return <IconAnthropic width={16} height={16} />
case "Google":
return <IconGoogle width={16} height={16} />
case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} />
case "Z.ai":

View File

@@ -1,111 +1,186 @@
/* Empty state */
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
/* Table container */
[data-slot="usage-table"] {
overflow-x: auto;
}
/* Table element */
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="usage-date"] {
color: var(--color-text);
}
&[data-slot="usage-model"] {
font-family: var(--font-sans);
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
}
&[data-slot="usage-cost"] {
color: var(--color-text);
font-weight: 500;
}
}
tbody tr:last-child td {
border-bottom: none;
}
}
/* Pagination */
[data-slot="pagination"] {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) 0;
border-top: 1px solid var(--color-border-muted);
margin-top: var(--space-2);
button {
padding: var(--space-2) var(--space-4);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
.root {
/* Empty state */
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
}
/* Mobile responsive */
@media (max-width: 40rem) {
/* Table container */
[data-slot="usage-table"] {
overflow-x: auto;
}
/* Table element */
[data-slot="usage-table-element"] {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
/* Hide Model column on mobile */
th:nth-child(2),
td:nth-child(2) {
display: none;
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="usage-date"] {
color: var(--color-text);
}
&[data-slot="usage-model"] {
font-family: var(--font-sans);
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
}
&[data-slot="usage-cost"] {
color: var(--color-text);
font-weight: 500;
}
[data-slot="tokens-with-breakdown"] {
position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
}
[data-slot="breakdown-button"] {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: var(--color-text);
}
svg {
width: 16px;
height: 16px;
}
}
[data-slot="breakdown-popup"] {
position: absolute;
left: 0;
top: 100%;
margin-top: var(--space-2);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
z-index: 10;
min-width: 180px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: var(--font-size-xs);
@media (prefers-color-scheme: dark) {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
}
tbody tr:last-child td {
border-bottom: none;
}
}
/* Pagination */
[data-slot="pagination"] {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) 0;
border-top: 1px solid var(--color-border-muted);
margin-top: var(--space-2);
button {
padding: var(--space-2) var(--space-4);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.15s ease;
svg {
width: 16px;
height: 16px;
stroke-width: 2;
}
&:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
/* Mobile responsive */
@media (max-width: 40rem) {
[data-slot="usage-table-element"] {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
/* Hide Model column on mobile */
th:nth-child(2),
td:nth-child(2) {
display: none;
}
}
}
/* Breakdown popup content */
[data-slot="breakdown-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-1) 0;
}
[data-slot="breakdown-label"] {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
[data-slot="breakdown-value"] {
color: var(--color-text);
font-weight: 500;
font-size: var(--font-size-xs);
}
}

View File

@@ -1,9 +1,10 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, For, Show, createEffect } from "solid-js"
import { createMemo, For, Show, createEffect, createSignal } from "solid-js"
import { formatDateUTC, formatDateForTable } from "../common"
import { withActor } from "~/context/auth.withActor"
import "./usage-section.module.css"
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
import styles from "./usage-section.module.css"
import { createStore } from "solid-js/store"
const PAGE_SIZE = 50
@@ -21,15 +22,34 @@ export function UsageSection() {
const params = useParams()
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
createEffect(() => {
setStore({ usage: usage() })
}, [usage])
createEffect(() => {
if (!openBreakdownId()) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('[data-slot="tokens-with-breakdown"]')) {
setOpenBreakdownId(null)
}
}
document.addEventListener("click", handleClickOutside)
return () => document.removeEventListener("click", handleClickOutside)
})
const hasResults = createMemo(() => store.usage && store.usage.length > 0)
const canGoPrev = createMemo(() => store.page > 0)
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
}
const goPrev = async () => {
const usage = await getUsageInfo(params.id!, store.page - 1)
setStore({
@@ -46,7 +66,7 @@ export function UsageSection() {
}
return (
<section>
<section class={styles.root}>
<div data-slot="section-title">
<h2>Usage History</h2>
<p>Recent API usage and costs.</p>
@@ -72,15 +92,50 @@ export function UsageSection() {
</thead>
<tbody>
<For each={store.usage}>
{(usage) => {
{(usage, index) => {
const date = createMemo(() => new Date(usage.timeCreated))
const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
const breakdownId = `breakdown-${index()}`
const isOpen = createMemo(() => openBreakdownId() === breakdownId)
const isClaude = usage.model.toLowerCase().includes("claude")
return (
<tr>
<td data-slot="usage-date" title={formatDateUTC(date())}>
{formatDateForTable(date())}
</td>
<td data-slot="usage-model">{usage.model}</td>
<td data-slot="usage-tokens">{usage.inputTokens}</td>
<td data-slot="usage-tokens">
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
<button
data-slot="breakdown-button"
onClick={(e) => {
e.stopPropagation()
setOpenBreakdownId(isOpen() ? null : breakdownId)
}}
>
<IconBreakdown />
</button>
<span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
<Show when={isOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Input</span>
<span data-slot="breakdown-value">{usage.inputTokens}</span>
</div>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Cache Read</span>
<span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
</div>
<Show when={isClaude}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Cache Write</span>
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
</div>
</Show>
</div>
</Show>
</div>
</td>
<td data-slot="usage-tokens">{usage.outputTokens}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr>
@@ -92,10 +147,10 @@ export function UsageSection() {
<Show when={canGoPrev() || canGoNext()}>
<div data-slot="pagination">
<button disabled={!canGoPrev()} onClick={goPrev}>
<IconChevronLeft />
</button>
<button disabled={!canGoNext()} onClick={goNext}>
<IconChevronRight />
</button>
</div>
</Show>

View File

@@ -15,6 +15,7 @@ import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter } from "./rateLimiter"
@@ -30,6 +31,8 @@ export async function handler(
opts: {
format: ZenData.Format
parseApiKey: (headers: Headers) => string | undefined
parseModel: (url: string, body: any) => string
parseIsStream: (url: string, body: any) => boolean
},
) {
type AuthInfo = Awaited<ReturnType<typeof authenticate>>
@@ -43,15 +46,18 @@ export async function handler(
]
try {
const url = input.request.url
const body = await input.request.json()
const ip = input.request.headers.get("x-real-ip") ?? ""
const model = opts.parseModel(url, body)
const isStream = opts.parseIsStream(url, body)
logger.metric({
is_tream: !!body.stream,
is_tream: isStream,
session: input.request.headers.get("x-opencode-session"),
request: input.request.headers.get("x-opencode-request"),
})
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, body.model)
const modelInfo = validateModel(zenData, model)
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
await rateLimiter?.check()
@@ -64,7 +70,7 @@ export async function handler(
logger.metric({ provider: providerInfo.id })
const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api)
const reqUrl = providerInfo.modifyUrl(providerInfo.api, providerInfo.model, isStream)
const reqBody = JSON.stringify(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
@@ -114,7 +120,7 @@ export async function handler(
logger.debug("STATUS: " + res.status + " " + res.statusText)
// Handle non-streaming response
if (!body.stream) {
if (!isStream) {
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
const json = await res.json()
const body = JSON.stringify(responseConverter(json))
@@ -169,7 +175,7 @@ export async function handler(
responseLength += value.length
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split("\n\n")
const parts = buffer.split(providerInfo.streamSeparator)
buffer = parts.pop() ?? ""
for (let part of parts) {
@@ -283,6 +289,7 @@ export async function handler(
...(() => {
const format = zenData.providers[provider.id].format
if (format === "anthropic") return anthropicHelper
if (format === "google") return googleHelper
if (format === "openai") return openaiHelper
return oaCompatHelper
})(),

View File

@@ -30,6 +30,7 @@ export const anthropicHelper = {
service_tier: "standard_only",
}
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage

View File

@@ -0,0 +1,74 @@
import { ProviderHelper } from "./provider"
/*
{
promptTokenCount: 11453,
candidatesTokenCount: 71,
totalTokenCount: 11625,
cachedContentTokenCount: 8100,
promptTokensDetails: [
{modality: "TEXT",tokenCount: 11453}
],
cacheTokensDetails: [
{modality: "TEXT",tokenCount: 8100}
],
thoughtsTokenCount: 101
}
*/
type Usage = {
promptTokenCount?: number
candidatesTokenCount?: number
totalTokenCount?: number
cachedContentTokenCount?: number
promptTokensDetails?: { modality: string; tokenCount: number }[]
cacheTokensDetails?: { modality: string; tokenCount: number }[]
thoughtsTokenCount?: number
}
export const googleHelper = {
format: "google",
modifyUrl: (providerApi: string, model?: string, isStream?: boolean) =>
`${providerApi}/models/${model}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`,
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("x-goog-api-key", apiKey)
},
modifyBody: (body: Record<string, any>) => {
return body
},
streamSeparator: "\r\n\r\n",
createUsageParser: () => {
let usage: Usage
return {
parse: (chunk: string) => {
if (!chunk.startsWith("data: ")) return
let json
try {
json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
} catch (e) {
return
}
if (!json.usageMetadata) return
usage = json.usageMetadata
},
retrieve: () => usage,
}
},
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.promptTokenCount ?? 0
const outputTokens = usage.candidatesTokenCount ?? 0
const reasoningTokens = usage.thoughtsTokenCount ?? 0
const cacheReadTokens = usage.cachedContentTokenCount ?? 0
return {
inputTokens: inputTokens - cacheReadTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
}
},
} satisfies ProviderHelper

View File

@@ -33,6 +33,7 @@ export const oaCompatHelper = {
...(body.stream ? { stream_options: { include_usage: true } } : {}),
}
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage

View File

@@ -21,6 +21,7 @@ export const openaiHelper = {
modifyBody: (body: Record<string, any>) => {
return body
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage

View File

@@ -26,9 +26,10 @@ import {
export type ProviderHelper = {
format: ZenData.Format
modifyUrl: (providerApi: string) => string
modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any>
streamSeparator: string
createUsageParser: () => {
parse: (chunk: string) => void
retrieve: () => any

View File

@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
return handler(input, {
format: "oa-compat",
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
parseModel: (url: string, body: any) => body.model,
parseIsStream: (url: string, body: any) => !!body.stream,
})
}

View File

@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
return handler(input, {
format: "anthropic",
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
parseModel: (url: string, body: any) => body.model,
parseIsStream: (url: string, body: any) => !!body.stream,
})
}

View File

@@ -0,0 +1,13 @@
import type { APIEvent } from "@solidjs/start/server"
import { handler } from "~/routes/zen/util/handler"
export function POST(input: APIEvent) {
return handler(input, {
format: "google",
parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined,
parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
parseIsStream: (url: string, body: any) =>
// ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse'
url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false,
})
}

View File

@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
return handler(input, {
format: "openai",
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
parseModel: (url: string, body: any) => body.model,
parseIsStream: (url: string, body: any) => !!body.stream,
})
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.68",
"version": "1.0.98",
"private": true,
"type": "module",
"dependencies": {

View File

@@ -11,20 +11,21 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const value1 = ret
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS1"))
?.split("=")[1]
const value2 = ret
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS2"))
?.split("=")[1]
const lines = ret.split("\n")
const value1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[1]
const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1]
const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 not found")
if (!value3) throw new Error("ZEN_MODELS3 not found")
if (!value4) throw new Error("ZEN_MODELS4 not found")
// validate value
ZenData.validate(JSON.parse(value1 + value2))
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4))
// update the secret
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS3 ${value3} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS4 ${value4} --stage ${stage}`

View File

@@ -9,21 +9,20 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
const models = await $`bun sst secret list`.cwd(root).text()
// read the line starting with "ZEN_MODELS"
const oldValue1 = models
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS1"))
?.split("=")[1]
const oldValue2 = models
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS2"))
?.split("=")[1]
const lines = models.split("\n")
const oldValue1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[1]
const oldValue2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1]
const oldValue3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
const oldValue4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
if (!oldValue3) throw new Error("ZEN_MODELS3 not found")
if (!oldValue4) throw new Error("ZEN_MODELS4 not found")
// store the prettified json to a temp file
const filename = `models-${Date.now()}.json`
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2), null, 2))
await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4), null, 2))
console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close
@@ -32,6 +31,12 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
ZenData.validate(JSON.parse(newValue))
// update the secret
const mid = Math.floor(newValue.length / 2)
await $`bun sst secret set ZEN_MODELS1 ${newValue.slice(0, mid)}`
await $`bun sst secret set ZEN_MODELS2 ${newValue.slice(mid)}`
const chunk = Math.ceil(newValue.length / 4)
const newValue1 = newValue.slice(0, chunk)
const newValue2 = newValue.slice(chunk, chunk * 2)
const newValue3 = newValue.slice(chunk * 2, chunk * 3)
const newValue4 = newValue.slice(chunk * 3)
await $`bun sst secret set ZEN_MODELS1 ${newValue1}`
await $`bun sst secret set ZEN_MODELS2 ${newValue2}`
await $`bun sst secret set ZEN_MODELS3 ${newValue3}`
await $`bun sst secret set ZEN_MODELS4 ${newValue4}`

View File

@@ -8,7 +8,7 @@ import { Actor } from "./actor"
import { Resource } from "@opencode-ai/console-resource"
export namespace ZenData {
const FormatSchema = z.enum(["anthropic", "openai", "oa-compat"])
const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
export type Format = z.infer<typeof FormatSchema>
const ModelCostSchema = z.object({
@@ -53,7 +53,9 @@ export namespace ZenData {
})
export const list = fn(z.void(), () => {
const json = JSON.parse(Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value)
const json = JSON.parse(
Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value + Resource.ZEN_MODELS3.value + Resource.ZEN_MODELS4.value,
)
return ModelsSchema.parse(json)
})
}

View File

@@ -94,6 +94,14 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
}
}
// cloudflare

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.68",
"version": "1.0.98",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -12,7 +12,8 @@ export default {
if (
url.pathname !== "/zen/v1/chat/completions" &&
url.pathname !== "/zen/v1/messages" &&
url.pathname !== "/zen/v1/responses"
url.pathname !== "/zen/v1/responses" &&
!url.pathname.startsWith("/zen/v1/models/")
)
return

View File

@@ -94,6 +94,14 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
}
}
// cloudflare

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.68",
"version": "1.0.98",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -94,6 +94,14 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
}
}
// cloudflare

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.68",
"version": "1.0.98",
"description": "",
"type": "module",
"scripts": {
@@ -14,7 +14,7 @@
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/luxon": "3.7.1",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
@@ -26,6 +26,7 @@
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/event-bus": "1.1.2",
@@ -33,7 +34,7 @@
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.3",
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",

View File

@@ -1,6 +1,7 @@
import { useLocal, type LocalFile } from "@/context/local"
import { Tooltip } from "@opencode-ai/ui"
import { Collapsible, FileIcon } from "@/ui"
import { Collapsible } from "@/ui"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"

View File

@@ -1,8 +1,6 @@
import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
@@ -11,6 +9,13 @@ import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "
import { useSDK } from "@/context/sdk"
import { useNavigate } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
interface PromptInputProps {
class?: string
@@ -184,8 +189,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const range = selection.getRangeAt(0)
if (atMatch) {
let node: Node | null = range.startContainer
let offset = range.startOffset
// let node: Node | null = range.startContainer
// let offset = range.startOffset
let runningLength = 0
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
@@ -266,7 +271,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!existing) {
const created = await sdk.client.session.create()
existing = created.data ?? undefined
if (existing) navigate(`/session/${existing.id}`)
if (existing) navigate(existing.id)
}
if (!existing) return
@@ -448,7 +453,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{(i) => (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0" />
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
<Show when={i.release_date}>

View File

@@ -1,104 +0,0 @@
import { useLocal } from "@/context/local"
import { useSession } from "@/context/session"
import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils"
import { Accordion, Button, Diff, DiffChanges, Icon, IconButton, Tooltip } from "@opencode-ai/ui"
import { For, Match, Show, Switch } from "solid-js"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { createStore } from "solid-js/store"
export const SessionReview = (props: { split?: boolean; class?: string; hideExpand?: boolean }) => {
const local = useLocal()
const session = useSession()
const [store, setStore] = createStore({
open: session.diffs().map((d) => d.file),
})
const handleChange = (open: string[]) => {
setStore("open", open)
}
const handleExpandOrCollapseAll = () => {
if (store.open.length > 0) {
setStore("open", [])
} else {
setStore(
"open",
session.diffs().map((d) => d.file),
)
}
}
return (
<div
classList={{
"flex flex-col gap-3 h-full overflow-y-auto no-scrollbar": true,
[props.class ?? ""]: !!props.class,
}}
>
<div class="sticky top-0 z-20 bg-background-stronger h-8 shrink-0 flex justify-between items-center self-stretch">
<div class="text-14-medium text-text-strong">Session changes</div>
<div class="flex items-center gap-x-4 pr-px">
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
<Switch>
<Match when={store.open.length > 0}>Collapse all</Match>
<Match when={true}>Expand all</Match>
</Switch>
</Button>
<Show when={!props.hideExpand}>
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
local.layout.review.tab()
session.layout.setActiveTab("review")
}}
/>
</Tooltip>
</Show>
</div>
</div>
<Accordion multiple value={store.open} onChange={handleChange}>
<For each={session.diffs()}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader class="top-11 data-expanded:before:-top-11">
<Accordion.Trigger class="bg-background-stronger">
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon node={{ path: diff.file, type: "file" }} class="shrink-0 size-4" />
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">{getDirectory(diff.file)}&lrm;</span>
</Show>
<span class="text-text-strong shrink-0">{getFilename(diff.file)}</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Diff
diffStyle={props.split ? "split" : "unified"}
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
)
}

View File

@@ -1,17 +0,0 @@
import { Accordion } from "@opencode-ai/ui"
import { ParentProps } from "solid-js"
export function StickyAccordionHeader(props: ParentProps<{ class?: string }>) {
return (
<Accordion.Header
classList={{
"sticky top-0 data-expanded:z-10": true,
"data-expanded:before:content-[''] data-expanded:before:z-[-10]": true,
"data-expanded:before:absolute data-expanded:before:inset-0 data-expanded:before:bg-background-stronger": true,
[props.class ?? ""]: !!props.class,
}}
>
{props.children}
</Accordion.Header>
)
}

View File

@@ -0,0 +1,32 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: (props: { url: string }) => {
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
sdk.global.event().then(async (events) => {
for await (const event of events.stream) {
// console.log("event", event)
emitter.emit(event.directory, event.payload)
}
})
onCleanup(() => {
abort.abort()
})
return { url: props.url, client: sdk, event: emitter }
},
})

View File

@@ -0,0 +1,183 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
Path,
File,
FileNode,
Project,
FileDiff,
Todo,
SessionStatus,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
type State = {
ready: boolean
provider: Provider[]
agent: Agent[]
project: Project
config: Config
path: Path
session: Session[]
session_status: {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: FileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
limit: number
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
node: FileNode[]
changes: File[]
}
export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
name: "GlobalSync",
init: () => {
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
defaultProject?: Project // TODO: remove this when we can select projects
projects: Project[]
children: Record<string, State>
}>({
ready: false,
projects: [],
children: {},
})
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) {
if (!children[directory]) {
setGlobalStore("children", directory, {
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
ready: false,
agent: [],
provider: [],
session: [],
session_status: {},
session_diff: {},
todo: {},
limit: 10,
message: {},
part: {},
node: [],
changes: [],
})
children[directory] = createStore(globalStore.children[directory])
}
return children[directory]
}
const sdk = useGlobalSDK()
sdk.event.listen((e) => {
const directory = e.name
const [store, setStore] = child(directory)
const event = e.details
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break
}
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.part.updated": {
const part = event.properties.part
const parts = store.part[part.messageID]
if (!parts) {
setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
setStore("part", part.messageID, result.index, reconcile(part))
break
}
setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
break
}
}
})
Promise.all([
sdk.client.project.list().then((x) =>
setGlobalStore(
"projects",
x.data!.filter((x) => !x.worktree.includes("opencode-test")),
),
),
// TODO: remove this when we can select projects
sdk.client.project.current().then((x) => setGlobalStore("defaultProject", x.data)),
]).then(() => setGlobalStore("ready", true))
return {
data: globalStore,
get ready() {
return globalStore.ready
},
child,
}
},
})

View File

@@ -1,25 +0,0 @@
import { createContext, Show, useContext, type ParentProps } from "solid-js"
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
name: string
init: ((input: Props) => T) | (() => T)
}) {
const ctx = createContext<T>()
return {
provider: (props: ParentProps<Props>) => {
const init = input.init(props)
return (
// @ts-expect-error
<Show when={init.ready === undefined || init.ready === true}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const value = useContext(ctx)
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
return value
},
}
}

View File

@@ -0,0 +1,75 @@
import { createStore } from "solid-js/store"
import { createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync"
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
const globalSync = useGlobalSync()
const [store, setStore] = makePersisted(
createStore({
projects: [] as { directory: string; expanded: boolean }[],
sidebar: {
opened: true,
width: 280,
},
review: {
state: "pane" as "pane" | "tab",
},
}),
{
name: "___default-layout",
},
)
return {
projects: {
list: createMemo(() =>
globalSync.data.defaultProject
? [{ directory: globalSync.data.defaultProject!.worktree, expanded: true }, ...store.projects]
: store.projects,
),
open(directory: string) {
if (store.projects.find((x) => x.directory === directory)) return
setStore("projects", (x) => [...x, { directory, expanded: true }])
},
close(directory: string) {
setStore("projects", (x) => x.filter((x) => x.directory !== directory))
},
expand(directory: string) {
setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: true } : x)))
},
collapse(directory: string) {
setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: false } : x)))
},
},
sidebar: {
opened: createMemo(() => store.sidebar.opened),
open() {
setStore("sidebar", "opened", true)
},
close() {
setStore("sidebar", "opened", false)
},
toggle() {
setStore("sidebar", "opened", (x) => !x)
},
width: createMemo(() => store.sidebar.width),
resize(width: number) {
setStore("sidebar", "width", width)
},
},
review: {
state: createMemo(() => store.review?.state ?? "closed"),
pane() {
setStore("review", "state", "pane")
},
tab() {
setStore("review", "state", "tab")
},
},
}
},
})

View File

@@ -2,10 +2,10 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
import { uniqueBy } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
import { createSimpleContext } from "./helper"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { makePersisted } from "@solid-primitives/storage"
import { base64Encode } from "@/utils"
export type LocalFile = FileNode &
Partial<{
@@ -457,57 +457,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})()
const layout = (() => {
const [store, setStore] = makePersisted(
createStore({
sidebar: {
opened: true,
width: 240,
},
review: {
state: "pane" as "pane" | "tab",
},
}),
{
name: "_default-layout",
},
)
return {
sidebar: {
opened: createMemo(() => store.sidebar.opened),
open() {
setStore("sidebar", "opened", true)
},
close() {
setStore("sidebar", "opened", false)
},
toggle() {
setStore("sidebar", "opened", (x) => !x)
},
width: createMemo(() => store.sidebar.width),
resize(width: number) {
setStore("sidebar", "width", width)
},
},
review: {
state: createMemo(() => store.review?.state ?? "closed"),
pane() {
setStore("review", "state", "pane")
},
tab() {
setStore("review", "state", "tab")
},
},
}
})()
const result = {
slug: createMemo(() => base64Encode(sdk.directory)),
model,
agent,
file,
context,
layout,
}
return result
},

View File

@@ -1,32 +1,32 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createSimpleContext } from "./helper"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { url: string }) => {
init: (props: { directory: string }) => {
const globalSDK = useGlobalSDK()
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: props.url,
baseUrl: globalSDK.url,
signal: abort.signal,
directory: props.directory,
})
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
console.log("event", event.type)
emitter.emit(event.type, event)
}
globalSDK.event.on(props.directory, async (event) => {
emitter.emit(event.type, event)
})
onCleanup(() => {
abort.abort()
})
return { client: sdk, event: emitter }
return { directory: props.directory, client: sdk, event: emitter }
},
})

View File

@@ -1,17 +1,22 @@
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "./sync"
import { makePersisted } from "@solid-primitives/storage"
import { TextSelection, useLocal } from "./local"
import { TextSelection } from "./local"
import { pipe, sumBy } from "remeda"
import { AssistantMessage } from "@opencode-ai/sdk"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@/utils"
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
name: "Session",
init: (props: { sessionId?: string }) => {
init: () => {
const params = useParams()
const sync = useSync()
const local = useLocal()
const name = createMemo(
() => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
)
const [store, setStore] = makePersisted(
createStore<{
@@ -30,17 +35,17 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
cursor: undefined,
}),
{
name: props.sessionId ?? "new-session",
name: name(),
},
)
createEffect(() => {
if (!props.sessionId) return
sync.session.sync(props.sessionId)
if (!params.id) return
sync.session.sync(params.id)
})
const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined))
const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : []))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
@@ -53,16 +58,13 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
if (!store.messageId) return lastUserMessage()
return userMessages()?.find((m) => m.id === store.messageId)
})
const working = createMemo(() => {
if (!props.sessionId) return false
const last = lastUserMessage()
if (!last) return false
const assistantMessages = sync.data.message[props.sessionId]?.filter(
(m) => m.role === "assistant" && m.parentID == last?.id,
) as AssistantMessage[]
const error = assistantMessages?.find((m) => m?.error)?.error
return !last?.summary?.body && !error
})
const status = createMemo(
() =>
sync.data.session_status[params.id ?? ""] ?? {
type: "idle",
},
)
const working = createMemo(() => status()?.type !== "idle")
const cost = createMemo(() => {
const total = pipe(
@@ -81,7 +83,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
const model = createMemo(() =>
last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
)
const diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : []))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const tokens = createMemo(() => {
if (!last()) return
@@ -97,8 +99,11 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
})
return {
id: props.sessionId,
get id() {
return params.id
},
info,
status,
working,
diffs,
prompt: {
@@ -140,9 +145,6 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
setStore("tabs", "active", undefined)
return
}
if (tab.startsWith("file://")) {
await local.file.open(tab.replace("file://", ""))
}
if (tab !== "review") {
if (!store.tabs.opened.includes(tab)) {
setStore("tabs", "opened", [...store.tabs.opened, tab])

View File

@@ -1,133 +1,17 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
Path,
File,
FileNode,
Project,
FileDiff,
Todo,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import type { Part } from "@opencode-ai/sdk"
import { produce } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@/utils/binary"
import { createSimpleContext } from "./helper"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
const [store, setStore] = createStore<{
ready: boolean
provider: Provider[]
agent: Agent[]
project: Project
config: Config
path: Path
session: Session[]
session_diff: {
[sessionID: string]: FileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
limit: number
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
node: FileNode[]
changes: File[]
}>({
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
ready: false,
agent: [],
provider: [],
session: [],
session_diff: {},
todo: {},
limit: 10,
message: {},
part: {},
node: [],
changes: [],
})
const globalSync = useGlobalSync()
const sdk = useSDK()
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.part.updated": {
const part = sanitizePart(event.properties.part)
const parts = store.part[part.messageID]
if (!parts) {
setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
setStore("part", part.messageID, result.index, reconcile(part))
break
}
setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
break
}
}
})
const [store, setStore] = globalSync.child(sdk.directory)
const load = {
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
@@ -142,6 +26,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.slice(0, store.limit)
setStore("session", sessions)
}),
status: () => sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),

View File

@@ -1 +1,7 @@
@import "@opencode-ai/ui/styles/tailwind";
:root {
a {
cursor: default;
}
}

View File

@@ -1,15 +1,19 @@
/* @refresh reload */
import "@/index.css"
import { render } from "solid-js/web"
import { Router, Route } from "@solidjs/router"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Fonts, MarkedProvider } from "@opencode-ai/ui"
import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync"
import { LocalProvider } from "./context/local"
import { Fonts } from "@opencode-ai/ui/fonts"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
import Layout from "@/pages/layout"
import SessionLayout from "@/pages/session-layout"
import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { LayoutProvider } from "./context/layout"
import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session"
import { base64Encode } from "./utils"
import { createMemo, Show } from "solid-js"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
@@ -30,20 +34,38 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
render(
() => (
<MarkedProvider>
<SDKProvider url={url}>
<SyncProvider>
<LocalProvider>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<MetaProvider>
<Fonts />
<Router root={Layout}>
<Route path={["/", "/session"]} component={SessionLayout}>
<Route path="/:id?" component={Session} />
<Route
path="/"
component={() => {
const globalSync = useGlobalSync()
const slug = createMemo(() => base64Encode(globalSync.data.defaultProject!.worktree))
return <Navigate href={`${slug()}/session`} />
}}
/>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<SessionProvider>
<Session />
</SessionProvider>
</Show>
)}
/>
</Route>
</Router>
</MetaProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</MarkedProvider>
),
root!,

View File

@@ -0,0 +1,32 @@
import { createMemo, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode } from "@/utils"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
export default function Layout(props: ParentProps) {
const params = useParams()
const sync = useGlobalSync()
const directory = createMemo(() => {
const decoded = base64Decode(params.dir!)
return sync.data.projects.find((x) => x.worktree === decoded)?.worktree ?? "/"
})
return (
<SDKProvider directory={directory()}>
<SyncProvider>
{iife(() => {
const sync = useSync()
return (
<DataProvider data={sync.data}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
})}
</SyncProvider>
</SDKProvider>
)
}

View File

@@ -0,0 +1,20 @@
import { useGlobalSync } from "@/context/global-sync"
import { base64Encode, getFilename } from "@/utils"
import { For } from "solid-js"
import { A } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
export default function Home() {
const sync = useGlobalSync()
return (
<div class="flex flex-col gap-3">
<For each={sync.data.projects}>
{(project) => (
<Button as={A} href={base64Encode(project.worktree)}>
{getFilename(project.worktree)}
</Button>
)}
</For>
</div>
)
}

View File

@@ -1,116 +1,198 @@
import { Button, Tooltip, DiffChanges, IconButton } from "@opencode-ai/ui"
import { createMemo, For, ParentProps, Show } from "solid-js"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { A, useParams } from "@solidjs/router"
import { useLocal } from "@/context/local"
import { useLayout } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { base64Encode, getFilename } from "@/utils"
import { Mark } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
export default function Layout(props: ParentProps) {
const params = useParams()
const sync = useSync()
const local = useLocal()
const globalSync = useGlobalSync()
const layout = useLayout()
const handleOpenProject = async () => {
// layout.projects.open(dir.)
}
return (
<div class="relative h-screen flex flex-col">
<header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
<div class="h-[calc(100vh-0rem)] flex">
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base">
<A
href="/"
classList={{
"w-12 shrink-0 px-4 py-3.5": true,
"flex items-center justify-start self-stretch": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
>
<Mark class="shrink-0" />
</A>
</header>
<div class="h-[calc(100vh-3rem)] flex">
<div
classList={{
"@container w-14 pb-4 shrink-0 bg-background-weak": true,
"flex flex-col items-start self-stretch justify-between": true,
"@container w-12 pb-5 shrink-0 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base": true,
"w-70": local.layout.sidebar.opened(),
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
>
<div class="flex flex-col justify-center items-start gap-4 self-stretch py-2 overflow-hidden mx-auto @[4rem]:mx-0">
<div class="h-8 shrink-0 flex items-center self-stretch px-3">
<Tooltip placement="right" value="Collapse sidebar">
<IconButton icon="layout-left" variant="ghost" size="large" onClick={local.layout.sidebar.toggle} />
</Tooltip>
</div>
<div class="w-full px-3">
<Button as={A} href="/session" class="hidden @[4rem]:flex w-full" size="large" icon="edit-small-2">
New Session
<div class="grow flex flex-col items-start self-stretch gap-4 p-2 min-h-0">
<Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
<Button
variant="ghost"
size="large"
class="group/sidebar-toggle shrink-0 w-full text-left justify-start"
onClick={layout.sidebar.toggle}
>
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
size="small"
class="group-hover/sidebar-toggle:hidden"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
size="small"
class="hidden group-hover/sidebar-toggle:inline-block"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
size="small"
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
<Show when={layout.sidebar.opened()}>
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
Toggle sidebar
</div>
</Show>
</Button>
<Tooltip placement="right" value="New session">
<IconButton as={A} href="/session" icon="edit-small-2" size="large" class="@[4rem]:hidden" />
</Tooltip>
</div>
<div class="hidden @[4rem]:flex size-full overflow-y-auto no-scrollbar flex-col flex-1 px-3">
<nav class="w-full">
<For each={sync.data.session}>
{(session) => {
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
</Tooltip>
<div class="flex flex-col justify-center items-start gap-4 self-stretch min-h-0">
<div class="hidden @[4rem]:flex size-full flex-col grow overflow-y-auto no-scrollbar">
<For each={layout.projects.list()}>
{(project) => {
const [store] = globalSync.child(project.directory)
const slug = createMemo(() => base64Encode(project.directory))
return (
<A
data-active={session.id === params.id}
href={`/session/${session.id}`}
class="group/session focus:outline-none cursor-default"
>
<Tooltip placement="right" value={session.title}>
<div
class="w-full mb-1.5 px-3 py-1 rounded-md
group-data-[active=true]/session:bg-surface-raised-base-hover
group-hover/session:bg-surface-raised-base-hover
group-focus/session:bg-surface-raised-base-hover"
>
<div class="flex items-center self-stretch gap-6 justify-between">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
{session.title}
</span>
<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>
</div>
<div class="flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
<Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
</div>
</div>
</Tooltip>
</A>
<Collapsible variant="ghost" defaultOpen class="gap-2">
<Button
as={"div"}
variant="ghost"
class="flex items-center justify-between gap-3 w-full h-8 pl-2 pr-2.25 self-stretch"
>
<Collapsible.Trigger class="p-0 text-left text-14-medium text-text-strong grow min-w-0 truncate">
{getFilename(project.directory)}
</Collapsible.Trigger>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" size="normal" />
</Button>
<Collapsible.Content>
<nav class="w-full flex flex-col gap-1.5">
<For each={store.session}>
{(session) => {
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
return (
<A
data-active={session.id === params.id}
href={`${slug()}/session/${session.id}`}
class="group/session focus:outline-none cursor-default"
>
<Tooltip placement="right" value={session.title}>
<div
class="w-full px-2 py-1 rounded-md
group-data-[active=true]/session:bg-surface-raised-base-hover
group-hover/session:bg-surface-raised-base-hover
group-focus/session:bg-surface-raised-base-hover"
>
<div class="flex items-center self-stretch gap-6 justify-between">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
{session.title}
</span>
<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>
</div>
<div class="hidden _flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
<Show when={session.summary}>
{(summary) => <DiffChanges changes={summary()} />}
</Show>
</div>
</div>
</Tooltip>
</A>
)
}}
</For>
</nav>
{/* <Show when={sync.session.more()}> */}
{/* <button */}
{/* class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong" */}
{/* onClick={() => sync.session.fetch()} */}
{/* > */}
{/* Show more */}
{/* </button> */}
{/* </Show> */}
</Collapsible.Content>
</Collapsible>
)
}}
</For>
</nav>
<Show when={sync.session.more()}>
<button
class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong"
onClick={() => sync.session.fetch()}
>
Show more
</button>
</Show>
</div>
</div>
</div>
<div class="flex flex-col items-start shrink-0 px-3 py-1 mx-auto @[4rem]:mx-0">
<Button
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
class="hidden @[4rem]:flex w-full text-12-medium text-text-base stroke-[1.5px]"
variant="ghost"
icon="speech-bubble"
>
Share feedback
</Button>
<Tooltip placement="right" value="Share feedback">
<IconButton
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
<Button
disabled
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
variant="ghost"
size="large"
icon="folder-add-left"
onClick={handleOpenProject}
>
<Show when={layout.sidebar.opened()}>Open project</Show>
</Button>
</Tooltip>
<Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}>
<Button
disabled
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
variant="ghost"
size="large"
icon="settings-gear"
>
<Show when={layout.sidebar.opened()}>Settings</Show>
</Button>
</Tooltip>
<Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
<Button
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
icon="speech-bubble"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
variant="ghost"
size="large"
class="@[4rem]:hidden stroke-[1.5px]"
/>
icon="bubble-5"
>
<Show when={layout.sidebar.opened()}>Share feedback</Show>
</Button>
</Tooltip>
</div>
</div>

View File

@@ -1,12 +0,0 @@
import { Show, type ParentProps } from "solid-js"
import { SessionProvider } from "@/context/session"
import { useParams } from "@solidjs/router"
export default function Layout(props: ParentProps) {
const params = useParams()
return (
<Show when={params.id || true} keyed>
<SessionProvider sessionId={params.id}>{props.children}</SessionProvider>
</Show>
)
}

View File

@@ -1,39 +1,20 @@
import {
SelectDialog,
IconButton,
Tabs,
Icon,
Accordion,
Diff,
Collapsible,
DiffChanges,
Message,
Typewriter,
Card,
Code,
Tooltip,
ProgressCircle,
Button,
} from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import { MessageProgress } from "@/components/message-progress"
import {
For,
onCleanup,
onMount,
Show,
Match,
Switch,
createSignal,
createEffect,
createMemo,
createResource,
} from "solid-js"
import { For, onCleanup, onMount, Show, Match, Switch, createResource } from "solid-js"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
import { PromptInput } from "@/components/prompt-input"
import { DateTime } from "luxon"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Code } from "@opencode-ai/ui/code"
import { SessionTimeline } from "@opencode-ai/ui/session-timeline"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import {
DragDropProvider,
DragDropSensors,
@@ -46,14 +27,11 @@ import {
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
import { Markdown } from "@opencode-ai/ui"
import { Spinner } from "@/components/spinner"
import { useSession } from "@/context/session"
import { StickyAccordionHeader } from "@/components/sticky-accordion-header"
import { SessionReview } from "@/components/session-review"
import { useLayout } from "@/context/layout"
export default function Page() {
const layout = useLayout()
const local = useLocal()
const sync = useSync()
const session = useSession()
@@ -63,7 +41,6 @@ export default function Page() {
activeDraggable: undefined as string | undefined,
})
let inputRef!: HTMLDivElement
let messageScrollElement!: HTMLDivElement
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
@@ -176,10 +153,16 @@ export default function Page() {
setStore("activeDraggable", undefined)
}
const FileVisual = (props: { file: LocalFile }): JSX.Element => {
const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
return (
<div class="flex items-center gap-x-1.5">
<FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
<FileIcon
node={props.file}
classList={{
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
"grayscale-0": props.active,
}}
/>
<span
classList={{
"text-14-medium": true,
@@ -287,7 +270,7 @@ export default function Page() {
<Tabs.List>
<Tabs.Trigger value="chat">
<div class="flex gap-x-[17px] items-center">
<div>Chat</div>
<div>Session</div>
<Tooltip
value={`${new Intl.NumberFormat("en-US", {
notation: "compact",
@@ -300,11 +283,11 @@ export default function Page() {
</Tooltip>
</div>
</Tabs.Trigger>
<Show when={local.layout.review.state() === "tab" && session.diffs().length}>
<Show when={layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Trigger
value="review"
closeButton={
<IconButton icon="collapse" size="normal" variant="ghost" onClick={local.layout.review.pane} />
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
}
>
<div class="flex items-center gap-3">
@@ -343,286 +326,18 @@ export default function Page() {
<div
classList={{
"w-full flex-1 min-h-0": true,
grid: local.layout.review.state() === "tab",
flex: local.layout.review.state() === "pane",
grid: layout.review.state() === "tab",
flex: layout.review.state() === "pane",
}}
>
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-xl mx-auto">
<Switch>
<Match when={session.id}>
<div
classList={{
"flex-1 min-h-0 pb-20": true,
"flex items-start justify-start": local.layout.review.state() === "pane",
}}
>
<Show when={session.messages.user().length > 1}>
<ul
role="list"
classList={{
"mr-8 shrink-0 flex flex-col items-start": true,
"absolute right-full w-60 mt-3 @7xl:gap-2 @7xl:mt-1": local.layout.review.state() === "tab",
"mt-3": local.layout.review.state() === "pane",
}}
>
<For each={session.messages.user()}>
{(message) => {
const assistantMessages = createMemo(() => {
if (!session.id) return []
return sync.data.message[session.id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const working = createMemo(() => !message.summary?.body && !error())
const handleClick = () => session.messages.setActive(message.id)
return (
<li
classList={{
"group/li flex items-center self-stretch justify-end": true,
"@7xl:justify-start": local.layout.review.state() === "tab",
}}
>
<Tooltip
placement="right"
gutter={8}
value={
<div class="flex items-center gap-2">
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
{message.summary?.title}
</div>
}
>
<button
data-active={session.messages.active()?.id === message.id}
onClick={handleClick}
classList={{
"group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
"data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
"@7xl:hidden": local.layout.review.state() === "tab",
}}
>
<div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
</button>
</Tooltip>
<button
classList={{
"hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
"@7xl:flex": local.layout.review.state() === "tab",
}}
onClick={handleClick}
>
<Switch>
<Match when={working()}>
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
</Match>
<Match when={true}>
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
</Match>
</Switch>
<div
data-active={session.messages.active()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
<Show when={message.summary?.title} fallback="New message">
{message.summary?.title}
</Show>
</div>
</button>
</li>
)
}}
</For>
</ul>
</Show>
<div ref={messageScrollElement} class="grow size-full min-w-0 overflow-y-auto no-scrollbar">
<For each={session.messages.user()}>
{(message) => {
const isActive = createMemo(() => session.messages.active()?.id === message.id)
const [titled, setTitled] = createSignal(!!message.summary?.title)
const assistantMessages = createMemo(() => {
if (!session.id) return []
return sync.data.message[session.id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
const [detailsExpanded, setDetailsExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id])
const hasToolPart = createMemo(() =>
assistantMessages()
?.flatMap((m) => sync.data.part[m.id])
.some((p) => p?.type === "tool"),
)
const working = createMemo(() => !message.summary?.body && !error())
// allowing time for the animations to finish
createEffect(() => {
const title = message.summary?.title
setTimeout(() => setTitled(!!title), 10_000)
})
createEffect(() => {
const summary = message.summary?.body
const complete = !!summary || !!error()
setTimeout(() => setCompleted(complete), 1200)
})
return (
<Show when={isActive()}>
<div
data-message={message.id}
class="flex flex-col items-start self-stretch gap-8 pb-20"
>
{/* Title */}
<div class="flex items-center gap-2 self-stretch sticky top-0 bg-background-stronger z-20 h-8">
<div class="w-full text-14-medium text-text-strong">
<Show
when={titled()}
fallback={
<Typewriter
as="h1"
text={message.summary?.title}
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
/>
}
>
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
{message.summary?.title}
</h1>
</Show>
</div>
</div>
<Message message={message} parts={parts()} />
{/* Summary */}
<Show when={completed()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">
<Switch>
<Match when={message.summary?.diffs?.length}>Summary</Match>
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={message.summary?.body}>
{(summary) => (
<Markdown
classList={{
"text-14-regular": !!message.summary?.diffs?.length,
"[&>*]:fade-up-text": !message.summary?.diffs?.length,
}}
text={summary()}
/>
)}
</Show>
</div>
<Accordion class="w-full" multiple>
<For each={message.summary?.diffs ?? []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader class="top-10 data-expanded:before:-top-10">
<Accordion.Trigger>
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon
node={{ path: diff.file, type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">
{getDirectory(diff.file)}&lrm;
</span>
</Show>
<span class="text-text-strong shrink-0">
{getFilename(diff.file)}
</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="max-h-60 overflow-y-auto no-scrollbar">
<Diff
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</Show>
<Show when={error() && !detailsExpanded()}>
<Card variant="error" class="text-text-on-critical-base">
{error()?.data?.message as string}
</Card>
</Show>
{/* Response */}
<div class="w-full">
<Switch>
<Match when={!completed()}>
<MessageProgress assistantMessages={assistantMessages} done={!working()} />
</Match>
<Match when={completed() && hasToolPart()}>
<Collapsible
variant="ghost"
open={detailsExpanded()}
onOpenChange={setDetailsExpanded}
>
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
<div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium">
<Switch>
<Match when={detailsExpanded()}>Hide details</Match>
<Match when={!detailsExpanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="w-full flex flex-col items-start self-stretch gap-3">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => sync.data.part[assistantMessage.id])
return <Message message={assistantMessage} parts={parts()} />
}}
</For>
<Show when={error()}>
<Card variant="error" class="text-text-on-critical-base">
{error()?.data?.message as string}
</Card>
</Show>
</div>
</Collapsible.Content>
</Collapsible>
</Match>
</Switch>
</div>
</div>
</Show>
)
}}
</For>
</div>
</div>
<SessionTimeline
sessionID={session.id!}
expanded={layout.review.state() === "tab" || !session.diffs().length}
classes={{ root: "pb-20", container: "pb-20" }}
/>
</Match>
<Match when={true}>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
@@ -654,25 +369,39 @@ export default function Page() {
/>
</div>
</div>
<Show when={local.layout.review.state() === "pane" && session.diffs().length}>
<Show when={layout.review.state() === "pane" && session.diffs().length}>
<div
classList={{
"relative grow px-6 py-3 flex-1 min-h-0 border-l border-border-weak-base": true,
}}
>
<SessionReview />
<SessionReview
diffs={session.diffs()}
actions={
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
layout.review.tab()
session.layout.setActiveTab("review")
}}
/>
</Tooltip>
}
/>
</div>
</Show>
</div>
</Tabs.Content>
<Show when={local.layout.review.state() === "tab" && session.diffs().length}>
<Show when={layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
<div
classList={{
"relative px-6 py-3 flex-1 min-h-0 overflow-hidden": true,
}}
>
<SessionReview split hideExpand class="pb-40" />
<SessionReview diffs={session.diffs()} split class="pb-40" />
</div>
</Tabs.Content>
</Show>
@@ -718,8 +447,8 @@ export default function Page() {
},
)
return (
<div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
<Show when={file()}>{(f) => <FileVisual file={f()} />}</Show>
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
</div>
)
}}
@@ -769,7 +498,13 @@ export default function Page() {
items={local.file.searchFiles}
key={(x) => x}
onOpenChange={(open) => setStore("fileSelectOpen", open)}
onSelect={(x) => (x ? session.layout.openTab("file://" + x) : undefined)}
onSelect={(x) => {
if (x) {
local.file.open(x)
return session.layout.openTab("file://" + x)
}
return undefined
}}
>
{(i) => (
<div

View File

@@ -1,7 +1,7 @@
import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible"
import { Icon, IconProps } from "@opencode-ai/ui/icon"
import { splitProps } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
import { Icon, type IconProps } from "@opencode-ai/ui"
export interface CollapsibleProps extends ComponentProps<typeof KobalteCollapsible> {}
export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {}

View File

@@ -4,4 +4,3 @@ export {
type CollapsibleTriggerProps,
type CollapsibleContentProps,
} from "./collapsible"
export { FileIcon, type FileIconProps } from "./file-icon"

View File

@@ -0,0 +1,7 @@
export function base64Encode(value: string) {
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
export function base64Decode(value: string) {
return atob(value.replace(/-/g, "+").replace(/_/g, "/"))
}

View File

@@ -1,2 +1,3 @@
export * from "./path"
export * from "./dom"
export * from "./encode"

View File

@@ -2,7 +2,6 @@ import { defineConfig } from "vite"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import path from "path"
import { iconsSpritesheet } from "vite-plugin-icons-spritesheet"
export default defineConfig({
resolve: {
@@ -10,18 +9,10 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
plugins: [
tailwindcss(),
solidPlugin(),
iconsSpritesheet({
withTypes: true,
inputDir: "src/assets/file-icons",
outputDir: "src/ui/file-icons",
formatter: "prettier",
}),
],
plugins: [tailwindcss(), solidPlugin()],
server: {
host: "0.0.0.0",
allowedHosts: true,
port: 3000,
},
build: {

28
packages/enterprise/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
dist
.wrangler
.output
.vercel
.netlify
.vinxi
app.config.timestamp_*.js
# Environment
.env
.env*.local
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,32 @@
# SolidStart
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
## Creating a project
```bash
# create a new project in the current directory
npm init solid@latest
# create a new project in my-app
npm init solid@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "@solidjs/start/config"
import tailwindcss from "@tailwindcss/vite"
export default defineConfig({
vite: {
plugins: [tailwindcss() as any],
server: {
host: "0.0.0.0",
allowedHosts: true,
},
},
})

View File

@@ -0,0 +1,35 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.98",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start"
},
"dependencies": {
"@opencode-ai/util": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@solidjs/meta": "catalog:",
"hono": "catalog:",
"hono-openapi": "catalog:",
"luxon": "catalog:",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
"zod": "catalog:"
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@typescript/native-preview": "catalog:",
"@types/luxon": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"engines": {
"node": ">=22"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

View File

@@ -0,0 +1,18 @@
@import "@opencode-ai/ui/styles/tailwind";
:root {
--background-rgb: 214, 219, 220;
--foreground-rgb: 0, 0, 0;
}
@media (prefers-color-scheme: dark) {
:root {
--background-rgb: 0, 0, 0;
--foreground-rgb: 255, 255, 255;
}
}
body {
/* background: rgb(var(--background-rgb)); */
/* color: rgb(var(--foreground-rgb)); */
}

View File

@@ -0,0 +1,28 @@
import { Router } from "@solidjs/router"
import { FileRoutes } from "@solidjs/start/router"
import { Suspense } from "solid-js"
import { Fonts } from "@opencode-ai/ui/fonts"
import { MetaProvider } from "@solidjs/meta"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import "./app.css"
export default function App() {
return (
<Router
root={(props) => (
<>
<Suspense>
<MarkedProvider>
<MetaProvider>
<Fonts />
{props.children}
</MetaProvider>
</MarkedProvider>
</Suspense>
</>
)}
>
<FileRoutes />
</Router>
)
}

View File

@@ -0,0 +1,139 @@
import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk"
import { fn } from "@opencode-ai/util/fn"
import { iife } from "@opencode-ai/util/iife"
import z from "zod"
import { Storage } from "./storage"
export namespace Share {
export const Info = z.object({
id: z.string(),
secret: z.string(),
})
export type Info = z.infer<typeof Info>
export const Data = z.discriminatedUnion("type", [
z.object({
type: z.literal("session"),
data: z.custom<Session>(),
}),
z.object({
type: z.literal("message"),
data: z.custom<Message>(),
}),
z.object({
type: z.literal("part"),
data: z.custom<Part>(),
}),
z.object({
type: z.literal("session_diff"),
data: z.custom<FileDiff[]>(),
}),
z.object({
type: z.literal("session_status"),
data: z.custom<SessionStatus>(),
}),
])
export type Data = z.infer<typeof Data>
export const create = fn(Info.pick({ id: true }), async (body) => {
const info: Info = {
id: body.id,
secret: crypto.randomUUID(),
}
const exists = await get(info.id)
if (exists) throw new Errors.AlreadyExists(info.id)
await Storage.write(["share", info.id], info)
console.log("created share", info.id)
return info
})
async function get(sessionID: string) {
return Storage.read<Info>(["share", sessionID])
}
export const remove = fn(Info.pick({ id: true, secret: true }), async (body) => {
const share = await get(body.id)
if (!share) throw new Errors.NotFound(body.id)
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
await Storage.remove(["share", body.id])
const list = await Storage.list(["share_data", body.id])
for (const item of list) {
await Storage.remove(item)
}
})
export async function data(sessionID: string) {
const list = await Storage.list(["share_data", sessionID])
const promises = []
for (const item of list) {
promises.push(
iife(async () => {
const [, , type] = item
return {
type: type as any,
data: await Storage.read<any>(item),
} as Data
}),
)
}
return await Promise.all(promises)
}
export const sync = fn(
z.object({
share: Info,
data: Data.array(),
}),
async (input) => {
const share = await get(input.share.id)
if (!share) throw new Errors.NotFound(input.share.id)
if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id)
const promises = []
for (const item of input.data) {
promises.push(
iife(async () => {
switch (item.type) {
case "session":
await Storage.write(["share_data", input.share.id, "session"], item.data)
break
case "message":
await Storage.write(["share_data", input.share.id, "message", item.data.id], item.data)
break
case "part":
await Storage.write(
["share_data", input.share.id, "part", item.data.messageID, item.data.id],
item.data,
)
break
case "session_diff":
await Storage.write(["share_data", input.share.id, "session_diff"], item.data)
break
case "session_status":
await Storage.write(["share_data", input.share.id, "session_status"], item.data)
break
}
}),
)
}
await Promise.all(promises)
},
)
export const Errors = {
NotFound: class extends Error {
constructor(public id: string) {
super(`Share not found: ${id}`)
}
},
InvalidSecret: class extends Error {
constructor(public id: string) {
super(`Share secret invalid: ${id}`)
}
},
AlreadyExists: class extends Error {
constructor(public id: string) {
super(`Share already exists: ${id}`)
}
},
}
}

View File

@@ -0,0 +1,134 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
} from "@aws-sdk/client-s3"
import { lazy } from "@opencode-ai/util/lazy"
export namespace Storage {
export interface Adapter {
read(path: string): Promise<string | undefined>
write(path: string, value: string): Promise<void>
remove(path: string): Promise<void>
list(prefix: string): Promise<string[]>
}
function createAdapter(client: S3Client, bucket: string): Adapter {
return {
async read(path: string): Promise<string | undefined> {
try {
console.log("reading", bucket, path)
const command = new GetObjectCommand({
Bucket: bucket,
Key: path,
})
const response = await client.send(command)
if (!response.Body) return undefined
return response.Body.transformToString()
} catch (e: any) {
if (e.name === "NoSuchKey") return undefined
throw e
}
},
async write(path: string, value: string): Promise<void> {
const command = new PutObjectCommand({
Bucket: bucket,
Key: path,
Body: value,
ContentType: "application/json",
})
await client.send(command)
},
async remove(path: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: bucket,
Key: path,
})
await client.send(command)
},
async list(prefix: string): Promise<string[]> {
const command = new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
})
const response = await client.send(command)
return response.Contents?.map((c) => c.Key!) || []
},
}
}
function s3(): Adapter {
const bucket = process.env.OPENCODE_STORAGE_BUCKET!
const client = new S3Client({
region: process.env.OPENCODE_STORAGE_REGION,
credentials: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID
? {
accessKeyId: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!,
secretAccessKey: process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!,
}
: undefined,
})
return createAdapter(client, bucket)
}
function r2() {
const accountId = process.env.OPENCODE_STORAGE_ACCOUNT_ID!
const accessKeyId = process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!
const secretAccessKey = process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!
const bucket = process.env.OPENCODE_STORAGE_BUCKET!
const client = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId,
secretAccessKey,
},
})
return createAdapter(client, bucket)
}
const adapter = lazy(() => {
const type = process.env.OPENCODE_STORAGE_ADAPTER
if (type === "r2") return r2()
if (type === "s3") return s3()
throw new Error("No storage adapter configured")
})
function resolve(key: string[]) {
return key.join("/") + ".json"
}
export async function read<T>(key: string[]) {
const result = await adapter().read(resolve(key))
if (!result) return undefined
return JSON.parse(result) as T
}
export function write<T>(key: string[], value: T) {
return adapter().write(resolve(key), JSON.stringify(value))
}
export function remove(key: string[]) {
return adapter().remove(resolve(key))
}
export async function list(prefix: string[]) {
const p = prefix.join("/") + (prefix.length ? "/" : "")
const result = await adapter().list(p)
return result.map((x) => x.replace(/\.json$/, "").split("/"))
}
export async function update<T>(key: string[], fn: (draft: T) => void) {
const val = await read<T>(key)
if (!val) throw new Error("Not found")
fn(val)
await write(key, val)
return val
}
}

View File

@@ -0,0 +1,4 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client"
mount(() => <StartClient />, document.getElementById("app")!)

View File

@@ -0,0 +1,22 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server"
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<title>OpenCode</title>
{assets}
</head>
<body class="antialiased overscroll-none select-none text-12-regular">
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
))

1
packages/enterprise/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />

View File

@@ -0,0 +1,25 @@
import { A } from "@solidjs/router"
export default function NotFound() {
return (
<main class="text-center mx-auto text-gray-700 p-4">
<h1 class="max-6-xs text-6xl text-sky-700 font-thin uppercase my-16">Not Found</h1>
<p class="mt-8">
Visit{" "}
<a href="https://solidjs.com" target="_blank" class="text-sky-600 hover:underline">
solidjs.com
</a>{" "}
to learn how to build Solid apps.
</p>
<p class="my-4">
<A href="/" class="text-sky-600 hover:underline">
Home
</A>
{" - "}
<A href="/about" class="text-sky-600 hover:underline">
About Page
</A>
</p>
</main>
)
}

View File

@@ -0,0 +1,152 @@
import type { APIEvent } from "@solidjs/start/server"
import { Hono } from "hono"
import { describeRoute, openAPIRouteHandler, resolver } from "hono-openapi"
import { validator } from "hono-openapi"
import z from "zod"
import { cors } from "hono/cors"
import { Share } from "~/core/share"
const app = new Hono()
app
.basePath("/api")
.use(cors())
.get(
"/doc",
openAPIRouteHandler(app, {
documentation: {
info: {
title: "Opencode Enterprise API",
version: "1.0.0",
description: "Opencode Enterprise API endpoints",
},
openapi: "3.1.1",
},
}),
)
.post(
"/share",
describeRoute({
description: "Create a share",
operationId: "share.create",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: resolver(
z
.object({
url: z.string(),
secret: z.string(),
})
.meta({ ref: "Share" }),
),
},
},
},
},
}),
validator("json", z.object({ sessionID: z.string() })),
async (c) => {
const body = c.req.valid("json")
const share = await Share.create({ id: body.sessionID })
const protocol = c.req.header("x-forwarded-proto") ?? c.req.header("x-forwarded-protocol") ?? "https"
const host = c.req.header("x-forwarded-host") ?? c.req.header("host")
return c.json({
secret: share.secret,
url: `${protocol}://${host}/share/${share.id}`,
})
},
)
.post(
"/share/:sessionID/sync",
describeRoute({
description: "Sync share data",
operationId: "share.sync",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: resolver(z.object({})),
},
},
},
},
}),
validator("param", z.object({ sessionID: z.string() })),
validator("json", z.object({ secret: z.string(), data: Share.Data.array() })),
async (c) => {
const { sessionID } = c.req.valid("param")
const body = c.req.valid("json")
await Share.sync({
share: { id: sessionID, secret: body.secret },
data: body.data,
})
return c.json({})
},
)
.get(
"/share/:sessionID/data",
describeRoute({
description: "Get share data",
operationId: "share.data",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: resolver(z.array(Share.Data)),
},
},
},
},
}),
validator("param", z.object({ sessionID: z.string() })),
async (c) => {
const { sessionID } = c.req.valid("param")
return c.json(await Share.data(sessionID))
},
)
.delete(
"/share/:sessionID",
describeRoute({
description: "Remove a share",
operationId: "share.remove",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: resolver(z.object({})),
},
},
},
},
}),
validator("param", z.object({ sessionID: z.string() })),
validator("json", z.object({ secret: z.string() })),
async (c) => {
const { sessionID } = c.req.valid("param")
const body = c.req.valid("json")
await Share.remove({ id: sessionID, secret: body.secret })
return c.json({})
},
)
export function GET(event: APIEvent) {
return app.fetch(event.request)
}
export function POST(event: APIEvent) {
return app.fetch(event.request)
}
export function PUT(event: APIEvent) {
return app.fetch(event.request)
}
export async function DELETE(event: APIEvent) {
return app.fetch(event.request)
}

View File

@@ -0,0 +1,5 @@
import { ParentProps } from "solid-js"
export default function Share(props: ParentProps) {
return props.children
}

View File

@@ -0,0 +1,172 @@
import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk"
import { SessionTimeline } from "@opencode-ai/ui/session-timeline"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider, useData } from "@opencode-ai/ui/context"
import { createAsync, query, RouteDefinition, useParams } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import { Share } from "~/core/share"
import { Logo, Mark } from "@opencode-ai/ui/logo"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary"
import { DateTime } from "luxon"
const getData = query(async (sessionID) => {
const data = await Share.data(sessionID)
const result: {
session: Session[]
session_diff: {
[sessionID: string]: FileDiff[]
}
session_status: {
[sessionID: string]: SessionStatus
}
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
} = {
session: [],
session_diff: {
[sessionID]: [],
},
session_status: {
[sessionID]: {
type: "idle",
},
},
message: {},
part: {},
}
for (const item of data) {
switch (item.type) {
case "session":
result.session.push(item.data)
break
case "session_diff":
result.session_diff[sessionID] = item.data
break
case "session_status":
result.session_status[sessionID] = item.data
break
case "message":
result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
result.message[item.data.sessionID].push(item.data)
break
case "part":
result.part[item.data.messageID] = result.part[item.data.messageID] ?? []
result.part[item.data.messageID].push(item.data)
break
}
}
return result
}, "getShareData")
export const route = {
preload: ({ params }) => getData(params.sessionID),
} satisfies RouteDefinition
export default function () {
const params = useParams()
const data = createAsync(async () => {
if (!params.sessionID) return
return getData(params.sessionID)
})
return (
<Show when={data()}>
{(data) => (
<DataProvider data={data()}>
{iife(() => {
const data = useData()
const match = createMemo(() => Binary.search(data.session, params.sessionID!, (s) => s.id))
if (!match().found) throw new Error(`Session ${params.sessionID} not found`)
const info = createMemo(() => data.session[match().index])
const firstUserMessage = createMemo(() =>
data.message[params.sessionID!]?.filter((m) => m.role === "user")?.at(0),
)
const provider = createMemo(() => firstUserMessage()?.model?.providerID)
const model = createMemo(() => firstUserMessage()?.model?.modelID)
const diffs = createMemo(() => data.session_diff[params.sessionID!] ?? [])
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div class="w-full flex-1 min-h-0 flex">
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
"px-21 @4xl:px-6 max-w-2xl": diffs().length > 0,
"px-6 max-w-2xl": diffs().length === 0,
}}
>
<div class="flex flex-col gap-4 shrink-0">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<img
src={`https://models.dev/logos/${provider()}.svg`}
class="size-4 shrink-0 dark:invert"
/>
<div class="text-12-regular text-text-base">{model()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
<SessionTimeline
sessionID={params.sessionID!}
classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
expanded
>
<div class="flex items-center justify-center pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTimeline>
</div>
<Show when={diffs().length}>
<div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview diffs={diffs()} class="pb-20" />
</div>
</Show>
</div>
</div>
</div>
)
})}
</DataProvider>
)}
</Show>
)
}

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