Compare commits

..
161 Commits
Author SHA1 Message Date
opencode 62fe1022fd release: v1.0.96
Update Nix Hashes / update (push) Has been cancelled
2025-11-22 04:55:15 +00:00
Dax Raad aabbd3383c fix dockerfile 2025-11-21 23:48:09 -05:00
opencode afb55cb7d4 release: v1.0.95
Update Nix Hashes / update (push) Has been cancelled
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
Update Nix Hashes / update (push) Has been cancelled
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 Actionandopencode 986cc0a01c chore: format code 2025-11-22 03:31:29 +00:00
opencode b20fd36c48 release: v1.0.92
Update Nix Hashes / update (push) Has been cancelled
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
Update Nix Hashes / update (push) Has been cancelled
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
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
Update Nix Hashes / update (push) Has been cancelled
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
Update Nix Hashes / update (push) Has been cancelled
2025-11-22 00:00:23 +00:00
Tommy D. RossiandGitHub 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
Update Nix Hashes / update (push) Has been cancelled
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
Update Nix Hashes / update (push) Has been cancelled
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 StarkovGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
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
Update Nix Hashes / update (push) Has been cancelled
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
AdamandGitHub 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 WolfandGitHub 416f2964b5 fix: preserve agent context during compaction (#4556) 2025-11-21 02:13:10 -06:00
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
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 Actionandopencode a67b616139 chore: format code 2025-11-21 06:03:38 +00:00
opencode 2991547974 release: v1.0.85
Update Nix Hashes / update (push) Has been cancelled
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
Update Nix Hashes / update (push) Has been cancelled
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
23ea8ba1ce Tui onboarding (#4569)
Co-authored-by: GitHub Action <action@github.com>
2025-11-21 00:21:06 -05:00
ZakGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-nodeAiden Cline
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
SpoonGitHubGitHub Actionopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
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 Actionandopencode 3b005d29d7 chore: format code 2025-11-21 00:49:39 +00:00
opencode 635f70f477 release: v1.0.83
Update Nix Hashes / update (push) Has been cancelled
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
Update Nix Hashes / update (push) Has been cancelled
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
JayandGitHub 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
jianandGitHub 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
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
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
Update Nix Hashes / update (push) Has been cancelled
2025-11-20 17:16:31 +00:00
Aiden Cline 40ac2549ff fix: aur build 2025-11-20 11:04:09 -06:00
cc-bjojacGitHubBjörn Jacobsopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
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
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
processtraderandGitHub 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
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
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 GongoraandGitHub bd91cf220c docs: typo in PHP Intelephense documentation (#4545) 2025-11-20 08:41:31 -06:00
Asmae_ELAZRAKandGitHub 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 Actionandopencode 208af232ff chore: format code 2025-11-20 01:27:50 +00:00
opencode 600c6b4973 release: v1.0.80
Update Nix Hashes / update (push) Has been cancelled
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
Update Nix Hashes / update (push) Has been cancelled
2025-11-20 01:11:20 +00:00
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 ClineandGitHub 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
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
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
DaxandGitHub 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
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
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
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
Update Nix Hashes / update (push) Has been cancelled
2025-11-18 23:36:35 +00:00
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
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 JudenandGitHub d6bf475749 docs: Improving Plugin Documentation - Adding Events (#4438) 2025-11-18 16:23:46 -06:00
opencode f22580e943 release: v1.0.77
Update Nix Hashes / update (push) Has been cancelled
2025-11-18 22:17:39 +00:00
Dax Raad 6d98db57c7 better gemini retry errors 2025-11-18 17:11:29 -05:00
OpeOginniandGitHub 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
LongloneGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
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
Update Nix Hashes / update (push) Has been cancelled
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]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-nodeAiden Cline
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
Update Nix Hashes / update (push) Has been cancelled
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 Actionandopencode 13f319b64f chore: format code 2025-11-18 17:16:07 +00:00
opencode b573eadd9e release: v1.0.74
Update Nix Hashes / update (push) Has been cancelled
2025-11-18 17:16:06 +00:00
Dax Raad 50bfff89c0 fix model dialog sorting 2025-11-18 12:10:19 -05:00
Adamandopencode fc5fc2c570 wip(desktop): new layout work 2025-11-18 17:07:34 +00:00
Adamandopencode 4069999b78 wip(desktop): new layout work 2025-11-18 17:07:34 +00:00
1319 changed files with 7099 additions and 2822 deletions
+16 -8
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
});
}
+7
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
+7 -2
View File
@@ -37,6 +37,11 @@ jobs:
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
@@ -62,7 +67,7 @@ jobs:
echo "" >> "$GITHUB_STEP_SUMMARY"
}
FILES=(flake.nix nix/node-modules.nix nix/hashes.json)
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"
@@ -71,7 +76,7 @@ jobs:
fi
git add "${FILES[@]}"
git commit -m "Update Nix hashes"
git commit -m "Update Nix flake.lock and hashes"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git push origin HEAD:"$BRANCH"
+1
View File
@@ -17,3 +17,4 @@ dist
refs
Session.vim
opencode.json
a.out
+3
View File
@@ -1,6 +1,9 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "http://localhost:3000",
// },
"provider": {
"opencode": {
"options": {
+1 -1
View File
@@ -87,7 +87,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
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.
+3
View File
@@ -144,3 +144,6 @@
| 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) |
View File
+380 -201
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1762156382,
"narHash": "sha256-Yg7Ag7ov5+36jEFC1DaZh/12SEXo6OO3/8rqADRxiqs=",
"lastModified": 1763618868,
"narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7241bcbb4f099a66aafca120d37c65e8dda32717",
"rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"type": "github"
},
"original": {
+7 -4
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!),
+143 -11
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 ""
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-lAU5G92UJ998pKOd0EAt9gUa/HUXXvu6xAUdH2STmDo="
"nodeModules": "sha256-HILcNy8B0k++ObGwUrD9dM0n9MvASR4AiyxmKSv3LV8="
}
+14 -8
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:*"
},
+5 -5
View File
@@ -1,13 +1,13 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.96",
"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.73"
"start": "vinxi start"
},
"dependencies": {
"@ibm/plex": "6.4.1",
@@ -17,9 +17,9 @@
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-mail": "workspace:*",
"@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",
@@ -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">
@@ -228,3 +236,14 @@ export function IconChevronRight(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</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>
)
}
+2 -2
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
@@ -1,4 +1,4 @@
import { and, Database, eq, gte, inArray, isNull, lte, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
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"
@@ -35,7 +35,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`,
model: UsageTable.model,
totalCost: sql<number>`SUM(${UsageTable.cost})`,
totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID,
})
.from(UsageTable)
@@ -46,7 +46,13 @@ async function getCosts(workspaceID: string, year: number, month: number) {
lte(UsageTable.timeCreated, endDate),
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID),
.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
@@ -204,18 +210,16 @@ export function GraphSection() {
const dates = getDates()
if (!data?.usage?.length) return null
const filteredUsageResults = store.key ? data.usage.filter((row) => row.keyId === store.key) : data.usage
const dailyData = new Map<string, Map<string, number>>()
for (const dateKey of dates) dailyData.set(dateKey, new Map())
for (const row of filteredUsageResults) {
const dayMap = dailyData.get(row.date)
if (dayMap) {
const existing = dayMap.get(row.model) || 0
dayMap.set(row.model, existing + row.totalCost)
}
}
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]
@@ -223,7 +227,7 @@ export function GraphSection() {
const color = getModelColor(model)
return {
label: model,
data: dates.map((date) => (dailyData.get(date)?.get(model) || 0) / 100000000),
data: dates.map((date) => (dailyData.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: color,
hoverBackgroundColor: color,
borderWidth: 0,
@@ -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":
@@ -56,6 +56,53 @@
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 {
@@ -116,4 +163,24 @@
}
}
}
/* 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);
}
}
@@ -1,9 +1,9 @@
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 { IconChevronLeft, IconChevronRight } from "~/component/icon"
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
import styles from "./usage-section.module.css"
import { createStore } from "solid-js/store"
@@ -22,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({
@@ -73,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>
@@ -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
})(),
@@ -30,6 +30,7 @@ export const anthropicHelper = {
service_tier: "standard_only",
}
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage
@@ -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
@@ -33,6 +33,7 @@ export const oaCompatHelper = {
...(body.stream ? { stream_options: { include_usage: true } } : {}),
}
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage
@@ -21,6 +21,7 @@ export const openaiHelper = {
modifyBody: (body: Record<string, any>) => {
return body
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage
@@ -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
@@ -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,
})
}
@@ -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,
})
}
@@ -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,
})
}
@@ -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,
})
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.73",
"version": "1.0.96",
"private": true,
"type": "module",
"dependencies": {
+10 -9
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}`
+17 -12
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}`
+4 -2
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)
})
}
+8
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.73",
"version": "1.0.96",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
@@ -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
+8
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.73",
"version": "1.0.96",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+8
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
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.73",
"version": "1.0.96",
"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:",
@@ -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"
@@ -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}>
@@ -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>
)
}
@@ -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>
)
}
@@ -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 }
},
})
@@ -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,
}
},
})
-25
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
},
}
}
+75
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")
},
},
}
},
})
+3 -48
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
},
+9 -9
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 }
},
})
+26 -24
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])
+8 -123
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!)),
+6
View File
@@ -1 +1,7 @@
@import "@opencode-ai/ui/styles/tailwind";
:root {
a {
cursor: default;
}
}
+36 -14
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!,
@@ -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>
)
}
+20
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>
)
}
+169 -87
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>
@@ -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>
)
}
+59 -324
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
+1 -1
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> {}
-1
View File
@@ -4,4 +4,3 @@ export {
type CollapsibleTriggerProps,
type CollapsibleContentProps,
} from "./collapsible"
export { FileIcon, type FileIconProps } from "./file-icon"
+7
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, "/"))
}
+1
View File
@@ -1,2 +1,3 @@
export * from "./path"
export * from "./dom"
export * from "./encode"
+2 -11
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
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
+32
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)
+12
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,
},
},
})
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.96",
"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

+18
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)); */
}
+28
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>
)
}
+139
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}`)
}
},
}
}
+134
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
}
}
+4
View File
@@ -0,0 +1,4 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client"
mount(() => <StartClient />, document.getElementById("app")!)
+22
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
View File
@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />
@@ -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>
)
}
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
import { ParentProps } from "solid-js"
export default function Share(props: ParentProps) {
return props.children
}
@@ -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>
)
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"noEmit": true,
"strict": true,
"types": ["vinxi/types/client"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
}
}
}
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The AI coding agent built for the terminal"
version = "1.0.73"
version = "1.0.96"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.73/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.96/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.73/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.96/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.73/opencode-linux-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.96/opencode-linux-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.73/opencode-linux-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.96/opencode-linux-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.73/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.96/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.73",
"version": "1.0.96",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+8
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
+10
View File
@@ -0,0 +1,10 @@
FROM alpine
# Disable the runtime transpiler cache by default inside Docker containers.
# On ephemeral containers, the cache is not useful
ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH}
RUN apk add libgcc libstdc++
ADD ./dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode
RUN opencode --version
+6 -4
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.73",
"version": "1.0.96",
"name": "opencode",
"type": "module",
"private": true,
@@ -44,6 +44,7 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/mcp": "0.0.8",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -54,8 +55,9 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.46",
"@opentui/solid": "0.1.46",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "0.1.47",
"@opentui/solid": "0.1.47",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -69,7 +71,7 @@
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "1.1.1",
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
+9
View File
@@ -167,6 +167,15 @@ export default {
],
},
},
{
filetype: "yaml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.2/tree-sitter-yaml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/yaml/highlights.scm",
],
},
},
{
filetype: "haskell",
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
+3 -1
View File
@@ -108,9 +108,11 @@ for (const item of targets) {
plugins: [solidPlugin],
sourcemap: "external",
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
target: name.replace(pkg.name, "bun") as any,
outfile: `dist/${name}/bin/opencode`,
execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
execArgv: [`--user-agent=opencode/${Script.version}`, "--"],
windows: {},
},
entrypoints: ["./src/index.ts", parserWorker, workerPath],
+46 -9
View File
@@ -59,12 +59,16 @@ if (!Script.preview) {
if (!Script.preview) {
for (const key of Object.keys(binaries)) {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
if (key.includes("linux")) {
await $`cd dist/${key}/bin && tar -czf ../../${key}.tar.gz *`
} else {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
}
}
// Calculate SHA values
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim())
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim())
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
@@ -88,10 +92,10 @@ if (!Script.preview) {
"conflicts=('opencode')",
"depends=('fzf' 'ripgrep')",
"",
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.zip")`,
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`,
`sha256sums_aarch64=('${arm64Sha}')`,
"",
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.zip")`,
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`,
`sha256sums_x86_64=('${x64Sha}')`,
"",
"package() {",
@@ -131,7 +135,34 @@ if (!Script.preview) {
"",
"package() {",
` cd "opencode-\${pkgver}/packages/opencode"`,
' install -Dm755 $(find dist/*/bin/opencode) "${pkgdir}/usr/bin/opencode"',
' mkdir -p "${pkgdir}/usr/bin"',
' target_arch="x64"',
' case "$CARCH" in',
' x86_64) target_arch="x64" ;;',
' aarch64) target_arch="arm64" ;;',
' *) printf "unsupported architecture: %s\\n" "$CARCH" >&2 ; return 1 ;;',
" esac",
' libc=""',
" if command -v ldd >/dev/null 2>&1; then",
" if ldd --version 2>&1 | grep -qi musl; then",
' libc="-musl"',
" fi",
" fi",
' if [ -z "$libc" ] && ls /lib/ld-musl-* >/dev/null 2>&1; then',
' libc="-musl"',
" fi",
' base=""',
' if [ "$target_arch" = "x64" ]; then',
" if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then",
' base="-baseline"',
" fi",
" fi",
' bin="dist/opencode-linux-${target_arch}${base}${libc}/bin/opencode"',
' if [ ! -f "$bin" ]; then',
' printf "unable to find binary for %s%s%s\\n" "$target_arch" "$base" "$libc" >&2',
" return 1",
" fi",
' install -Dm755 "$bin" "${pkgdir}/usr/bin/opencode"',
"}",
"",
].join("\n")
@@ -189,14 +220,14 @@ if (!Script.preview) {
"",
" on_linux do",
" if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-x64.zip"`,
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-x64.tar.gz"`,
` sha256 "${x64Sha}"`,
" def install",
' bin.install "opencode"',
" end",
" end",
" if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-arm64.zip"`,
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-arm64.tar.gz"`,
` sha256 "${arm64Sha}"`,
" def install",
' bin.install "opencode"',
@@ -214,4 +245,10 @@ if (!Script.preview) {
await $`cd ./dist/homebrew-tap && git add opencode.rb`
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push`
const image = "ghcr.io/sst/opencode"
await $`docker build -t ${image}:${Script.version} .`
await $`docker push ${image}:${Script.version}`
await $`docker tag ${image}:${Script.version} ${image}:latest`
await $`docker push ${image}:latest`
}
+42 -10
View File
@@ -79,16 +79,48 @@ export namespace BunProc {
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
const total = 3
const wait = 500
const runInstall = async (count: number = 1): Promise<void> => {
log.info("bun install attempt", {
pkg,
version,
attempt: count,
total,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch(async (error) => {
log.warn("bun install failed", {
pkg,
version,
attempt: count,
total,
error,
})
if (count >= total) {
throw new InstallFailedError(
{ pkg, version },
{
cause: error,
},
)
}
const delay = wait * count
log.info("bun install retrying", {
pkg,
version,
next: count + 1,
delay,
})
await Bun.sleep(delay)
return runInstall(count + 1)
})
}
await runInstall()
parsed.dependencies[pkg] = version
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
return mod
+25 -1
View File
@@ -6,6 +6,7 @@ import { Agent } from "../../agent/agent"
import path from "path"
import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { EOL } from "os"
const AgentCreateCommand = cmd({
command: "create",
@@ -133,9 +134,32 @@ const AgentCreateCommand = cmd({
},
})
const AgentListCommand = cmd({
command: "list",
describe: "list all available agents",
async handler() {
await Instance.provide({
directory: process.cwd(),
async fn() {
const agents = await Agent.list()
const sortedAgents = agents.sort((a, b) => {
if (a.builtIn !== b.builtIn) {
return a.builtIn ? -1 : 1
}
return a.name.localeCompare(b.name)
})
for (const agent of sortedAgents) {
process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
}
},
})
},
})
export const AgentCommand = cmd({
command: "agent",
describe: "manage agents",
builder: (yargs) => yargs.command(AgentCreateCommand).demandCommand(),
builder: (yargs) => yargs.command(AgentCreateCommand).command(AgentListCommand).demandCommand(),
async handler() {},
})
+22 -11
View File
@@ -2,10 +2,11 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch } from "solid-js"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
@@ -293,6 +294,14 @@ function App() {
},
category: "System",
},
{
title: "Connect provider",
value: "provider.connect",
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
category: "System",
},
{
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
value: "theme.switch_mode",
@@ -451,16 +460,18 @@ function App() {
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box>
</box>
<box flexDirection="row" flexShrink={0}>
<text fg={theme.textMuted} paddingRight={1}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
<text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}>
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>
</box>
<Show when={false}>
<box flexDirection="row" flexShrink={0}>
<text fg={theme.textMuted} paddingRight={1}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
<text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}>
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>
</box>
</Show>
</box>
</box>
)
@@ -1,16 +1,21 @@
export const EmptyBorder = {
topLeft: "",
bottomLeft: "",
vertical: "",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}
export const SplitBorder = {
border: ["left" as const, "right" as const],
customBorderChars: {
topLeft: "",
bottomLeft: "",
...EmptyBorder,
vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: "",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
},
}
@@ -1,26 +1,27 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda"
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useTheme } from "../context/theme"
function Free() {
const { theme } = useTheme()
return <span style={{ fg: theme.secondary }}>Free</span>
}
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
export function DialogModel() {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const { theme } = useTheme()
const connected = createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected())
const providers = createDialogProviderOptions()
const options = createMemo(() => {
return [
...(!ref()?.filter
...(showRecent()
? local.model.recent().flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
if (!provider) return []
@@ -36,7 +37,17 @@ export function DialogModel() {
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
footer: model.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined,
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
@@ -57,27 +68,56 @@ export function DialogModel() {
modelID: model,
},
title: info.name ?? model,
description: provider.name,
category: provider.name,
footer: info.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined,
description: connected() ? provider.name : undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model,
},
{ recent: true },
)
},
})),
filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
sortBy((x) => x.title),
),
),
),
...(!connected()
? pipe(
providers(),
map((option) => {
return {
...option,
category: "Popular providers",
}
}),
take(6),
)
: []),
]
})
return (
<DialogSelect
keybind={[
{
keybind: { ctrl: true, name: "a", meta: false, shift: false, leader: false },
title: connected() ? "Connect provider" : "More providers",
onTrigger() {
dialog.replace(() => <DialogProvider />)
},
},
]}
ref={setRef}
title="Select model"
current={local.model.current()}
options={options()}
onSelect={(option) => {
dialog.clear()
local.model.set(option.value, { recent: true })
}}
/>
)
}
@@ -0,0 +1,222 @@
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useSync } from "@tui/context/sync"
import { map, pipe, sortBy } from "remeda"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "../context/sdk"
import { DialogPrompt } from "../ui/dialog-prompt"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk"
import { DialogModel } from "./dialog-model"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
}
export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
map((provider) => ({
title: provider.name,
value: provider.id,
footer: {
opencode: "Recommended",
anthropic: "Claude Max or API key",
}[provider.id],
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
path: {
id: provider.id,
},
body: {
method: index,
},
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
}
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99),
)
})
return options
}
export function DialogProvider() {
const options = createDialogProviderOptions()
return <DialogSelect title="Connect a provider" options={options()} />
}
interface AutoMethodProps {
index: number
providerID: string
title: string
authorization: ProviderAuthAuthorization
}
function AutoMethod(props: AutoMethodProps) {
const { theme } = useTheme()
const sdk = useSDK()
const dialog = useDialog()
const sync = useSync()
onMount(async () => {
const result = await sdk.client.provider.oauth.callback({
path: {
id: props.providerID,
},
body: {
method: props.index,
},
})
if (result.error) {
dialog.clear()
return
}
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
})
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box gap={1}>
<text fg={theme.primary}>{props.authorization.url}</text>
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
</box>
<text fg={theme.textMuted}>Waiting for authorization...</text>
</box>
)
}
interface CodeMethodProps {
index: number
title: string
providerID: string
authorization: ProviderAuthAuthorization
}
function CodeMethod(props: CodeMethodProps) {
const { theme } = useTheme()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const [error, setError] = createSignal(false)
return (
<DialogPrompt
title={props.title}
placeholder="Authorization code"
onConfirm={async (value) => {
const { error } = await sdk.client.provider.oauth.callback({
path: {
id: props.providerID,
},
body: {
method: props.index,
code: value,
},
})
if (!error) {
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
return
}
setError(true)
}}
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
<text fg={theme.primary}>{props.authorization.url}</text>
<Show when={error()}>
<text fg={theme.error}>Invalid code</text>
</Show>
</box>
)}
/>
)
}
interface ApiMethodProps {
providerID: string
title: string
}
function ApiMethod(props: ApiMethodProps) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
return (
<DialogPrompt
title={props.title}
placeholder="API key"
onConfirm={async (value) => {
if (!value) return
sdk.client.auth.set({
path: {
id: props.providerID,
},
body: {
type: "api",
key: value,
},
})
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
}}
/>
)
}
@@ -53,15 +53,7 @@ export function Autocomplete(props: {
// Track props.value to make memo reactive to text changes
props.value // <- there surely is a better way to do this, like making .input() reactive
const val = props.input().getTextRange(store.index + 1, props.input().cursorOffset + 1)
// If the filter contains a space, hide the autocomplete
if (val.includes(" ")) {
hide()
return undefined
}
return val
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
@@ -387,17 +379,19 @@ export function Autocomplete(props: {
get visible() {
return store.visible
},
onInput() {
onInput(value) {
if (store.visible) {
if (props.input().cursorOffset <= store.index) {
if (
// Typed text before the trigger
props.input().cursorOffset <= store.index ||
// There is a space between the trigger and the cursor
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
) {
hide()
return
}
// Check if a space was typed after the trigger character
const currentText = props.input().getTextRange(store.index + 1, props.input().cursorOffset + 1)
if (currentText.includes(" ")) {
hide()
}
}
},
onKeyDown(e: KeyEvent) {
@@ -1,18 +1,8 @@
import {
TextAttributes,
BoxRenderable,
TextareaRenderable,
MouseEvent,
PasteEvent,
t,
dim,
fg,
type KeyBinding,
} from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js"
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { EmptyBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
@@ -29,6 +19,8 @@ import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { Shimmer } from "../../ui/shimmer"
export type PromptProps = {
sessionID?: string
@@ -57,7 +49,7 @@ export function Prompt(props: PromptProps) {
const sdk = useSDK()
const route = useRoute()
const sync = useSync()
const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
@@ -222,12 +214,17 @@ export function Prompt(props: PromptProps) {
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
disabled: status() !== "working",
disabled: status().type === "idle",
category: "Session",
onSelect: (dialog) => {
if (!props.sessionID) return
if (autocomplete.visible) return
if (!input.focused) return
// TODO: this should be its own command
if (store.mode === "shell") {
setStore("mode", "normal")
return
}
if (!props.sessionID) return
setStore("interrupt", store.interrupt + 1)
@@ -425,6 +422,10 @@ export function Prompt(props: PromptProps) {
},
body: {
agent: local.agent.current().name,
model: {
providerID: local.model.current().providerID,
modelID: local.model.current().modelID,
},
command: inputText,
},
})
@@ -538,6 +539,16 @@ export function Prompt(props: PromptProps) {
return
}
const highlight = createMemo(() => {
if (keybind.leader) return theme.border
if (store.mode === "shell") return theme.primary
return local.agent.color(local.agent.current().name)
})
createEffect(() => {
renderer.setCursorColor(highlight())
})
return (
<>
<Autocomplete
@@ -562,17 +573,22 @@ export function Prompt(props: PromptProps) {
/>
<box ref={(r) => (anchor = r)}>
<box
flexDirection="row"
{...SplitBorder}
borderColor={keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border}
justifyContent="space-evenly"
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box backgroundColor={theme.backgroundElement} width={3} height="100%" alignItems="center" paddingTop={1}>
<text attributes={TextAttributes.BOLD} fg={theme.primary}>
{store.mode === "normal" ? ">" : "!"}
</text>
</box>
<box paddingTop={1} paddingBottom={1} backgroundColor={theme.backgroundElement} flexGrow={1}>
<box
paddingLeft={2}
paddingRight={1}
paddingTop={1}
flexShrink={0}
backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<textarea
placeholder={
props.showPlaceholder
@@ -747,37 +763,151 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.primary}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<text flexShrink={0} fg={theme.text}>
{local.model.parsed().model}
</text>
</box>
</Show>
</box>
</box>
<box backgroundColor={theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
</box>
<box
height={1}
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "╹",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={{
...EmptyBorder,
horizontal: "▀",
}}
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<text flexShrink={0} wrapMode="none" fg={theme.text}>
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
<Switch>
<Match when={status() === "compacting"}>
<text fg={theme.textMuted}>compacting...</text>
</Match>
<Match when={status() === "working"}>
<box flexDirection="row" gap={1}>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
<Show when={status().type !== "idle"} fallback={<text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<Loader />
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini 3 way too hot right now"
if (r.message.length > 50) return r.message.slice(0, 50) + "..."
return r.message
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
return (
<Show when={retry()}>
<text fg={theme.error}>
{message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""}
attempt #{retry()!.attempt}]
</text>
</Show>
)
})()}
</box>
</box>
</Match>
<Match when={props.hint}>{props.hint!}</Match>
<Match when={true}>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</Match>
</Switch>
</box>
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span>
</text>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</Show>
</box>
</box>
</>
)
}
function Loader() {
const FRAMES = [
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▰▱▱▱▱▱▱",
"▰▰▱▱▱▱▱",
"▰▰▰▱▱▱▱",
"▱▰▰▰▱▱▱",
"▱▱▰▰▰▱▱",
"▱▱▱▰▰▰▱",
"▱▱▱▱▰▰▰",
"▱▱▱▱▱▰▰",
"▱▱▱▱▱▱▰",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
]
const [frame, setFrame] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
setFrame((frame() + 1) % FRAMES.length)
}, 100)
onCleanup(() => {
clearInterval(timer)
})
})
const { theme } = useTheme()
return <text fg={theme.diffAdded}>{FRAMES[frame()]}</text>
}
@@ -10,6 +10,7 @@ import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
import { Provider } from "@/provider/provider"
import { useArgs } from "./args"
import { RGBA } from "@opentui/core"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
@@ -91,7 +92,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
color(name: string) {
const agent = agents().find((x) => x.name === name)
if (agent?.color) return agent.color
if (agent?.color) return RGBA.fromHex(agent.color)
const index = agents().findIndex((x) => x.name === name)
return colors()[index % colors().length]
},

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