Compare commits

...
127 Commits
Author SHA1 Message Date
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 Action 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. Rossi 1d7a9309d6 feat: lower opacity for thinking summaries (#4610) 2025-11-21 17:35:47 -06:00
Dax Raad f5ac98251e tui: split revert memo into smaller tracked computations to prevent unnecessary re-evaluations 2025-11-21 18:27:25 -05:00
GitHub Action 78239045ba chore: format code 2025-11-21 22:42:39 +00:00
Dax Raad d8b60875c4 tui: batch SDK events to reduce render churn and improve responsiveness
Intelligently queue events and flush them in 16ms batches. Events processed within 16ms of the last flush are batched together to reduce the number of store updates and re-renders, preventing jank when receiving rapid consecutive events. Events after a 16ms gap are processed immediately to avoid adding latency.
2025-11-21 17:42:02 -05:00
opencode 48949a6e9d release: v1.0.88
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 Starkovopencode-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
Adam 08a366c4dc feat(install): better install script visuals, custom progress bar and next steps (#4589) 2025-11-21 07:29:52 -06:00
GitHub Action 670e1523e0 ignore: update download stats 2025-11-21 2025-11-21 12:04:49 +00:00
Patrick Wolf 416f2964b5 fix: preserve agent context during compaction (#4556) 2025-11-21 02:13:10 -06:00
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
geril07andAiden Cline c0d9f21c0f feat: whitelist/blacklist config options for provider (#3416)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-21 01:01:30 -06:00
GitHub Action a67b616139 chore: format code 2025-11-21 06:03:38 +00:00
opencode 2991547974 release: v1.0.85
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
DaxandGitHub Action 23ea8ba1ce Tui onboarding (#4569)
Co-authored-by: GitHub Action <action@github.com>
2025-11-21 00:21:06 -05:00
Zakopencode-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
SpoonGitHub 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 Action 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
Jay 8063e645c7 docs: Clarify model provider recommendations in README
Updated the README to clarify model provider recommendations.
2025-11-20 17:21:19 -05:00
Aiden Cline 8ab206b443 tweak: navigate to child session if it is asking for permissions 2025-11-20 15:46:49 -06:00
Aiden Cline ec5c96e10d Revert "Image tag fix for real this time (#4540)"
This reverts commit eb975bb89c.
2025-11-20 14:14:02 -06:00
Aiden Cline d2a61290b9 Revert "ignore: write todo"
This reverts commit 5aa2078852.
2025-11-20 14:13:56 -06:00
Aiden Cline 10faf9e717 ignore: fix mdx 2025-11-20 14:07:25 -06:00
Aiden Cline cba239bc8f change default child session cycle keybind to <leader>right, and <leader>left 2025-11-20 14:01:02 -06:00
jian 6f5e3ddfb3 chore: rm empty file (#4552) 2025-11-20 13:33:09 -06:00
GitHub Action d412ba264a chore: format code 2025-11-20 19:17:06 +00:00
Aiden Cline 9780f2b792 docs: update model list 2025-11-20 13:16:27 -06:00
Aiden Cline 5aa2078852 ignore: write todo 2025-11-20 12:35:34 -06:00
eb975bb89c Image tag fix for real this time (#4540)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-20 12:34:39 -06:00
GitHub Action 9479fe3ce6 chore: format code 2025-11-20 17:35:43 +00:00
William TanandAiden Cline 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-bjojacBjö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
Haris GušićandGitHub Action b1b73c9deb fix: Autocomplete with existing space after trigger (#4121)
Co-authored-by: GitHub Action <action@github.com>
2025-11-20 10:37:45 -06:00
Aiden Cline 774377330b fix: lockfile 2025-11-20 10:16:34 -06:00
processtrader e7a157ef8f fix: replace ruby-lsp with rubocop for better LSP performance (#4543) 2025-11-20 09:54:47 -06:00
Frank 3989b9fc7f wip: zen 2025-11-20 10:31:35 -05:00
GitHub Action 8bfcdf4831 chore: format code 2025-11-20 14:55:06 +00:00
Frank 3632ba3785 zen: show token breakdown 2025-11-20 09:54:22 -05:00
Albert O'SheaandGithub Action b7b3824d76 nix: update flake.lock as part of the github workflow (#4535)
Co-authored-by: Github Action <action@github.com>
2025-11-20 08:43:13 -06:00
Tommy D. RossiandCopilot b12efb2023 fix: do not print ai sdk warnings (#4541)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-20 08:42:24 -06:00
Roberto Gongora bd91cf220c docs: typo in PHP Intelephense documentation (#4545) 2025-11-20 08:41:31 -06:00
Asmae_ELAZRAK 9eb6731c21 docs: add cortecs docs (#4547) 2025-11-20 08:34:04 -06:00
GitHub Action 11373755d9 ignore: update download stats 2025-11-20 2025-11-20 12:04:55 +00:00
Aiden Cline 00b5e9f6ca chore: rm unused code 2025-11-20 00:10:51 -06:00
Aiden Cline 6b3f424e4d tweak: let user name export file 2025-11-19 23:58:40 -06:00
Aiden Cline e7dfeec9c4 fix: make toast wrap 2025-11-19 22:50:27 -06:00
Aiden Cline 97893bd7e6 tweak: make /export go to cwd 2025-11-19 22:45:40 -06:00
Frank bfefdb3752 zen: add gemini icon 2025-11-19 21:14:31 -05:00
Frank 12b79c581e zen: update gemini provider name 2025-11-19 20:58:22 -05:00
Frank ac9b4c7ebf docs: add gemini 3 pricing 2025-11-19 20:50:43 -05:00
GitHub Action 208af232ff chore: format code 2025-11-20 01:27:50 +00:00
opencode 600c6b4973 release: v1.0.80
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 Cline 61562dd9f0 make aur build check if u are glibc system or a musl system (#4519) 2025-11-19 18:36:33 -06:00
Github Action c86dd91310 Update Nix hashes 2025-11-19 23:30:37 +00:00
Sebastian Herrlinger 9c85a37811 bump opentui version to v0.1.47
- fixing cursor issues with some graphemes in textarea
- proper suspend/resume
2025-11-20 00:28:25 +01:00
Aiden Cline 51bba6e634 tweak: default to disabling fetch timeout in provider options 2025-11-19 16:20:29 -06:00
Daniel PolitoandAiden Cline 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
IljoandAiden Cline 4703e859bd Add YAML language server support (#4508)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-19 14:47:04 -06:00
Aiden Cline a1dc4ebbe4 ignore: flaky test be a lil less flaky plz 2025-11-19 14:46:06 -06:00
Aiden Cline e4e6096510 ignore: fix hanging test 2025-11-19 14:38:12 -06:00
Aiden Cline c472734933 tweak: make getUsage function handle missing usage data 2025-11-19 14:29:19 -06:00
Aiden Cline 9d068c20bb fix: openrouter ai sdk package support 2025-11-19 14:22:51 -06:00
Aiden Cline 48e4f2f45d tweak: add bun install retries 2025-11-19 13:04:20 -06:00
Aiden Cline bbf4574476 fix: make external_directory permission wildcarding more sane 2025-11-19 12:55:02 -06:00
Adam 8bad513140 Revert "feat(cli): better install script output"
This reverts commit 24bb293136.
2025-11-19 12:44:35 -06:00
Aiden Cline 1ff5d888c2 fix: make bash tool use external_directory perm 2025-11-19 12:31:34 -06:00
Dax Raad 5d25758400 use bash description as task title 2025-11-19 13:23:29 -05:00
Dax 16fdc90976 fix: resolve issue 4475 (#4505) 2025-11-19 13:10:09 -05:00
Aiden Cline 793542230f tweak: bash description 2025-11-19 11:31:12 -06:00
Tommy D. RossiandCopilot 9de1242d9b fix: show reasoning summaries for gemini models (#4491)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-19 11:25:18 -06:00
Aiden Cline b3afa84058 Revert "Added subagents to agents modal, non-selectable (#4460)"
This reverts commit 90044196bf.
2025-11-19 11:00:38 -06:00
Aiden Cline 024a10bbb5 ci: auto label nix 2025-11-19 10:51:11 -06:00
Adam bef9ac96e2 fix(web): stats 2025-11-19 10:05:39 -06:00
Adam 24bb293136 feat(cli): better install script output 2025-11-19 09:30:41 -06:00
Adam 45180104fe fix(desktop): message animation 2025-11-19 06:04:20 -06:00
Adam edd86e3fb7 fix(desktop): text part styling 2025-11-19 06:04:20 -06:00
Adam 4a72d57534 fix(desktop): pre styling 2025-11-19 06:04:19 -06:00
Aiden Cline 0068cb305f tweak: toast 2025-11-19 00:51:07 -06:00
opencode-agent[bot]andAiden Cline 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
1298 changed files with 6056 additions and 2533 deletions
+16 -8
View File
@@ -28,14 +28,14 @@ jobs:
const versionPattern = /[v]?1\.0\./i; const versionPattern = /[v]?1\.0\./i;
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description); 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) { if (isWebRelated) {
// Add web label labels.push('web');
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['web']
});
// Assign to adamdotdevin // Assign to adamdotdevin
await github.rest.issues.addAssignees({ await github.rest.issues.addAssignees({
@@ -46,10 +46,18 @@ jobs:
}); });
} else if (isVersionRelated) { } else if (isVersionRelated) {
// Only add opentui if NOT web-related // Only add opentui if NOT web-related
labels.push('opentui');
}
if (isNixRelated) {
labels.push('nix');
}
if (labels.length > 0) {
await github.rest.issues.addLabels({ await github.rest.issues.addLabels({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: issue.number, issue_number: issue.number,
labels: ['opentui'] labels: labels
}); });
} }
+7
View File
@@ -61,6 +61,13 @@ jobs:
run: | run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc 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 - name: Publish
run: | run: |
./script/publish.ts ./script/publish.ts
+7 -2
View File
@@ -37,6 +37,11 @@ jobs:
git config --global user.email "action@github.com" git config --global user.email "action@github.com"
git config --global user.name "Github Action" git config --global user.name "Github Action"
- name: Update flake.lock
run: |
set -euo pipefail
nix flake update
- name: Update node_modules hash - name: Update node_modules hash
run: | run: |
set -euo pipefail set -euo pipefail
@@ -62,7 +67,7 @@ jobs:
echo "" >> "$GITHUB_STEP_SUMMARY" 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)" STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then if [ -z "$STATUS" ]; then
summarize "no changes" summarize "no changes"
@@ -71,7 +76,7 @@ jobs:
fi fi
git add "${FILES[@]}" git add "${FILES[@]}"
git commit -m "Update Nix hashes" git commit -m "Update Nix flake.lock and hashes"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git push origin HEAD:"$BRANCH" git push origin HEAD:"$BRANCH"
+1
View File
@@ -17,3 +17,4 @@ dist
refs refs
Session.vim Session.vim
opencode.json opencode.json
a.out
+3
View File
@@ -1,6 +1,9 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"], "plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "http://localhost:3000",
// },
"provider": { "provider": {
"opencode": { "opencode": {
"options": { "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: It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source - 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 - 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 focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients. - A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
+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-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) | | 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) | | 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
| 2025-11-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": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1762156382, "lastModified": 1763618868,
"narHash": "sha256-Yg7Ag7ov5+36jEFC1DaZh/12SEXo6OO3/8rqADRxiqs=", "narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "7241bcbb4f099a66aafca120d37c65e8dda32717", "rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"type": "github" "type": "github"
}, },
"original": { "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_MODELS = [
const ZEN_MODELS2 = new sst.Secret("ZEN_MODELS2") 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 STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) }, properties: { value: auth.url.apply((url) => url!) },
@@ -132,11 +136,10 @@ new sst.cloudflare.x.SolidStart("Console", {
AUTH_API_URL, AUTH_API_URL,
STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET,
STRIPE_SECRET_KEY, STRIPE_SECRET_KEY,
ZEN_MODELS1,
ZEN_MODELS2,
EMAILOCTOPUS_API_KEY, EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID, AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
...ZEN_MODELS,
...($dev ...($dev
? [ ? [
new sst.Secret("CLOUDFLARE_DEFAULT_ACCOUNT_ID", process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID!), new sst.Secret("CLOUDFLARE_DEFAULT_ACCOUNT_ID", process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID!),
+140 -8
View File
@@ -2,9 +2,8 @@
set -euo pipefail set -euo pipefail
APP=opencode APP=opencode
MUTED='\033[0;2m'
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
ORANGE='\033[38;2;255;140;0m' ORANGE='\033[38;2;255;140;0m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
@@ -26,7 +25,11 @@ elif [[ "$arch" == "x86_64" ]]; then
arch="x64" arch="x64"
fi fi
if [ "$os" = "linux" ]; then
filename="$APP-$os-$arch.tar.gz"
else
filename="$APP-$os-$arch.zip" filename="$APP-$os-$arch.zip"
fi
case "$filename" in case "$filename" in
@@ -45,6 +48,18 @@ case "$filename" in
;; ;;
esac 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 INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
@@ -67,8 +82,8 @@ print_message() {
local color="" local color=""
case $level in case $level in
info) color="${GREEN}" ;; info) color="${NC}" ;;
warning) color="${YELLOW}" ;; warning) color="${NC}" ;;
error) color="${RED}" ;; error) color="${RED}" ;;
esac esac
@@ -86,19 +101,119 @@ check_version() {
installed_version=$(echo $installed_version | awk '{print $2}') installed_version=$(echo $installed_version | awk '{print $2}')
if [[ "$installed_version" != "$specific_version" ]]; then if [[ "$installed_version" != "$specific_version" ]]; then
print_message info "Installed version: ${YELLOW}$installed_version." print_message info "${MUTED}Installed version: ${NC}$installed_version."
else else
print_message info "Version ${YELLOW}$specific_version${GREEN} already installed" print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
exit 0 exit 0
fi fi
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() { 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 mkdir -p opencodetmp && cd opencodetmp
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" curl -# -L -o "$filename" "$url"
fi
if [ "$os" = "linux" ]; then
tar -xzf "$filename"
else
unzip -q "$filename" unzip -q "$filename"
fi
mv opencode "$INSTALL_DIR" mv opencode "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode" chmod 755 "${INSTALL_DIR}/opencode"
cd .. && rm -rf opencodetmp cd .. && rm -rf opencodetmp
@@ -117,7 +232,7 @@ add_to_path() {
elif [[ -w $config_file ]]; then elif [[ -w $config_file ]]; then
echo -e "\n# opencode" >> "$config_file" echo -e "\n# opencode" >> "$config_file"
echo "$command" >> "$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 else
print_message warning "Manually add the directory to $config_file (or similar):" print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " $command" print_message info " $command"
@@ -191,3 +306,20 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
echo "$INSTALL_DIR" >> $GITHUB_PATH echo "$INSTALL_DIR" >> $GITHUB_PATH
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH" print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
fi 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", "description": "AI-powered development tool",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.2", "packageManager": "bun@1.3.3",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'" "random": "echo 'Random script'",
"hello": "echo 'Hello World!'"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
@@ -19,33 +20,37 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@types/bun": "1.3.0", "@types/bun": "1.3.3",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.4.4", "@pierre/precision-diffs": "0.5.4",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"ai": "5.0.8", "ai": "5.0.97",
"hono": "4.7.10", "hono": "4.7.10",
"hono-openapi": "1.1.1",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"luxon": "3.6.1", "luxon": "3.6.1",
"typescript": "5.8.2", "typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251014.1", "@typescript/native-preview": "7.0.0-dev.20251014.1",
"zod": "4.1.8", "zod": "4.1.8",
"remeda": "2.26.0", "remeda": "2.26.0",
"solid-js": "1.9.9",
"solid-list": "0.3.0", "solid-list": "0.3.0",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"virtua": "0.42.3", "virtua": "0.42.3",
"vite": "7.1.4", "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": { "devDependencies": {
@@ -56,6 +61,7 @@
"turbo": "2.5.6" "turbo": "2.5.6"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*" "@opencode-ai/sdk": "workspace:*"
}, },
+5 -5
View File
@@ -1,13 +1,13 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.0.93",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"dev": "vinxi dev --host 0.0.0.0", "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", "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", "build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start", "start": "vinxi start"
"version": "1.0.78"
}, },
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -17,9 +17,9 @@
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-mail": "workspace:*", "@opencode-ai/console-mail": "workspace:*",
"@opencode-ai/console-resource": "workspace:*", "@opencode-ai/console-resource": "workspace:*",
"@solidjs/meta": "^0.29.4", "@solidjs/meta": "catalog:",
"@solidjs/router": "^0.15.0", "@solidjs/router": "catalog:",
"@solidjs/start": "^1.1.0", "@solidjs/start": "catalog:",
"chart.js": "4.5.1", "chart.js": "4.5.1",
"solid-js": "catalog:", "solid-js": "catalog:",
"vinxi": "^0.5.7", "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>) { export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return ( return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none"> <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> </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) // Static stats (used on landing page)
stats: { stats: {
contributors: "250", contributors: "300",
commits: "3,500", commits: "4,000",
monthlyUsers: "300,000", monthlyUsers: "300,000",
}, },
} as const } 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 { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
@@ -35,7 +35,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
.select({ .select({
date: sql<string>`DATE(${UsageTable.timeCreated})`, date: sql<string>`DATE(${UsageTable.timeCreated})`,
model: UsageTable.model, model: UsageTable.model,
totalCost: sql<number>`SUM(${UsageTable.cost})`, totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID, keyId: UsageTable.keyID,
}) })
.from(UsageTable) .from(UsageTable)
@@ -46,7 +46,13 @@ async function getCosts(workspaceID: string, year: number, month: number) {
lte(UsageTable.timeCreated, endDate), 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 // Get unique key IDs from usage
@@ -204,18 +210,16 @@ export function GraphSection() {
const dates = getDates() const dates = getDates()
if (!data?.usage?.length) return null 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>>() const dailyData = new Map<string, Map<string, number>>()
for (const dateKey of dates) dailyData.set(dateKey, new Map()) for (const dateKey of dates) dailyData.set(dateKey, new Map())
for (const row of filteredUsageResults) { data.usage
.filter((row) => (store.key ? row.keyId === store.key : true))
.forEach((row) => {
const dayMap = dailyData.get(row.date) const dayMap = dailyData.get(row.date)
if (dayMap) { if (!dayMap) return
const existing = dayMap.get(row.model) || 0 dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
dayMap.set(row.model, existing + row.totalCost) })
}
}
const filteredModels = store.model === null ? getModels() : [store.model] const filteredModels = store.model === null ? getModels() : [store.model]
@@ -223,7 +227,7 @@ export function GraphSection() {
const color = getModelColor(model) const color = getModelColor(model)
return { return {
label: model, 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, backgroundColor: color,
hoverBackgroundColor: color, hoverBackgroundColor: color,
borderWidth: 0, borderWidth: 0,
@@ -5,11 +5,21 @@ import { withActor } from "~/context/auth.withActor"
import { ZenData } from "@opencode-ai/console-core/model.js" import { ZenData } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css" import styles from "./model-section.module.css"
import { querySessionInfo } from "../common" 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) => { const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic" if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI" if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("gemini")) return "Google"
if (modelId.startsWith("kimi")) return "Moonshot AI" if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai" if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba" if (modelId.startsWith("qwen")) return "Alibaba"
@@ -24,7 +34,17 @@ const getModelsInfo = query(async (workspaceID: string) => {
all: Object.entries(ZenData.list().models) all: Object.entries(ZenData.list().models)
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id)) .filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
.filter(([id, _model]) => !id.startsWith("alpha-")) .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 })), .map(([id, model]) => ({ id, name: model.name })),
disabled: await Model.listDisabled(), disabled: await Model.listDisabled(),
} }
@@ -96,6 +116,8 @@ export function ModelSection() {
return <IconOpenAI width={16} height={16} /> return <IconOpenAI width={16} height={16} />
case "Anthropic": case "Anthropic":
return <IconAnthropic width={16} height={16} /> return <IconAnthropic width={16} height={16} />
case "Google":
return <IconGoogle width={16} height={16} />
case "Moonshot AI": case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} /> return <IconMoonshotAI width={16} height={16} />
case "Z.ai": case "Z.ai":
@@ -56,6 +56,53 @@
color: var(--color-text); color: var(--color-text);
font-weight: 500; 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 { 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 { Billing } from "@opencode-ai/console-core/billing.js"
import { createAsync, query, useParams } from "@solidjs/router" 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 { formatDateUTC, formatDateForTable } from "../common"
import { withActor } from "~/context/auth.withActor" 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 styles from "./usage-section.module.css"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -22,15 +22,34 @@ export function UsageSection() {
const params = useParams() const params = useParams()
const usage = createAsync(() => queryUsageInfo(params.id!, 0)) const usage = createAsync(() => queryUsageInfo(params.id!, 0))
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> }) const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
createEffect(() => { createEffect(() => {
setStore({ usage: usage() }) setStore({ usage: 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 hasResults = createMemo(() => store.usage && store.usage.length > 0)
const canGoPrev = createMemo(() => store.page > 0) const canGoPrev = createMemo(() => store.page > 0)
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE) 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 goPrev = async () => {
const usage = await getUsageInfo(params.id!, store.page - 1) const usage = await getUsageInfo(params.id!, store.page - 1)
setStore({ setStore({
@@ -73,15 +92,50 @@ export function UsageSection() {
</thead> </thead>
<tbody> <tbody>
<For each={store.usage}> <For each={store.usage}>
{(usage) => { {(usage, index) => {
const date = createMemo(() => new Date(usage.timeCreated)) 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 ( return (
<tr> <tr>
<td data-slot="usage-date" title={formatDateUTC(date())}> <td data-slot="usage-date" title={formatDateUTC(date())}>
{formatDateForTable(date())} {formatDateForTable(date())}
</td> </td>
<td data-slot="usage-model">{usage.model}</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-tokens">{usage.outputTokens}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td> <td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr> </tr>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.78", "version": "1.0.93",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+10 -9
View File
@@ -11,20 +11,21 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret // read the secret
const ret = await $`bun sst secret list`.cwd(root).text() const ret = await $`bun sst secret list`.cwd(root).text()
const value1 = ret const lines = ret.split("\n")
.split("\n") const value1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[1]
.find((line) => line.startsWith("ZEN_MODELS1")) const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1]
?.split("=")[1] const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
const value2 = ret const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS2"))
?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found") if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 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 // validate value
ZenData.validate(JSON.parse(value1 + value2)) ZenData.validate(JSON.parse(value1 + value2 + value3 + value4))
// update the secret // update the secret
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}` 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_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() const models = await $`bun sst secret list`.cwd(root).text()
// read the line starting with "ZEN_MODELS" // read the line starting with "ZEN_MODELS"
const oldValue1 = models const lines = models.split("\n")
.split("\n") const oldValue1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[1]
.find((line) => line.startsWith("ZEN_MODELS1")) const oldValue2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1]
?.split("=")[1] const oldValue3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
const oldValue2 = models const oldValue4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS2"))
?.split("=")[1]
if (!oldValue1) throw new Error("ZEN_MODELS1 not found") if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
if (!oldValue2) throw new Error("ZEN_MODELS2 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 // store the prettified json to a temp file
const filename = `models-${Date.now()}.json` const filename = `models-${Date.now()}.json`
const tempFile = Bun.file(path.join(os.tmpdir(), filename)) 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) console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close // 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)) ZenData.validate(JSON.parse(newValue))
// update the secret // update the secret
const mid = Math.floor(newValue.length / 2) const chunk = Math.ceil(newValue.length / 4)
await $`bun sst secret set ZEN_MODELS1 ${newValue.slice(0, mid)}` const newValue1 = newValue.slice(0, chunk)
await $`bun sst secret set ZEN_MODELS2 ${newValue.slice(mid)}` 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}`
+3 -1
View File
@@ -53,7 +53,9 @@ export namespace ZenData {
}) })
export const list = fn(z.void(), () => { 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) return ModelsSchema.parse(json)
}) })
} }
+8
View File
@@ -94,6 +94,14 @@ declare module "sst" {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
} }
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
} }
} }
// cloudflare // cloudflare
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.78", "version": "1.0.93",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+8
View File
@@ -94,6 +94,14 @@ declare module "sst" {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
} }
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
} }
} }
// cloudflare // cloudflare
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.78", "version": "1.0.93",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+8
View File
@@ -94,6 +94,14 @@ declare module "sst" {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
} }
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
} }
} }
// cloudflare // cloudflare
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.0.78", "version": "1.0.93",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -14,7 +14,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@types/luxon": "3.7.1", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
@@ -26,6 +26,7 @@
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3", "@solid-primitives/active-element": "2.1.3",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -33,7 +34,7 @@
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.3", "@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
@@ -1,6 +1,7 @@
import { useLocal, type LocalFile } from "@/context/local" import { useLocal, type LocalFile } from "@/context/local"
import { Tooltip } from "@opencode-ai/ui" import { Collapsible } from "@/ui"
import { Collapsible, FileIcon } 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 { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web" 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 { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils" import { getDirectory, getFilename } from "@/utils"
import { createFocusSignal } from "@solid-primitives/active-element" import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -11,6 +9,13 @@ import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useSync } from "@/context/sync" 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 { interface PromptInputProps {
class?: string class?: string
@@ -184,8 +189,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const range = selection.getRangeAt(0) const range = selection.getRangeAt(0)
if (atMatch) { if (atMatch) {
let node: Node | null = range.startContainer // let node: Node | null = range.startContainer
let offset = range.startOffset // let offset = range.startOffset
let runningLength = 0 let runningLength = 0
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null) const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
@@ -1,104 +0,0 @@
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"
import { useLayout } from "@/context/layout"
export const SessionReview = (props: { split?: boolean; class?: string; hideExpand?: boolean }) => {
const layout = useLayout()
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={() => {
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>
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client" 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 { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js" import { onCleanup } from "solid-js"
+2 -2
View File
@@ -14,8 +14,8 @@ import type {
SessionStatus, SessionStatus,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@/utils/binary" import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
type State = { type State = {
-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
},
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage" import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
+1 -1
View File
@@ -2,7 +2,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js" import { batch, createEffect, createMemo } from "solid-js"
import { uniqueBy } from "remeda" import { uniqueBy } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk" 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 { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { base64Encode } from "@/utils" import { base64Encode } from "@/utils"
+1 -1
View File
@@ -1,5 +1,5 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client" 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 { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js" import { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
+2 -2
View File
@@ -1,5 +1,5 @@
import { createStore, produce } from "solid-js/store" 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 { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "./sync" import { useSync } from "./sync"
import { makePersisted } from "@solid-primitives/storage" import { makePersisted } from "@solid-primitives/storage"
@@ -60,7 +60,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
}) })
const status = createMemo( const status = createMemo(
() => () =>
sync.data.session_status[params.id] ?? { sync.data.session_status[params.id ?? ""] ?? {
type: "idle", type: "idle",
}, },
) )
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Part } from "@opencode-ai/sdk" import type { Part } from "@opencode-ai/sdk"
import { produce } from "solid-js/store" import { produce } from "solid-js/store"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { Binary } from "@/utils/binary" import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
+2 -1
View File
@@ -3,7 +3,8 @@ import "@/index.css"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { Router, Route, Navigate } from "@solidjs/router" import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { Fonts, MarkedProvider } from "@opencode-ai/ui" import { Fonts } from "@opencode-ai/ui/fonts"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync" import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
@@ -1,22 +1,31 @@
import { createMemo, type ParentProps } from "solid-js" import { createMemo, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { SyncProvider } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { base64Decode } from "@/utils" import { base64Decode } from "@/utils"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const sync = useGlobalSync() const sync = useGlobalSync()
const directory = createMemo(() => { const directory = createMemo(() => {
const decoded = base64Decode(params.dir) const decoded = base64Decode(params.dir!)
return sync.data.projects.find((x) => x.worktree === decoded)?.worktree ?? "/" return sync.data.projects.find((x) => x.worktree === decoded)?.worktree ?? "/"
}) })
return ( return (
<SDKProvider directory={directory()}> <SDKProvider directory={directory()}>
<SyncProvider> <SyncProvider>
{iife(() => {
const sync = useSync()
return (
<DataProvider data={sync.data}>
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
})}
</SyncProvider> </SyncProvider>
</SDKProvider> </SDKProvider>
) )
+1 -1
View File
@@ -2,7 +2,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { base64Encode, getFilename } from "@/utils" import { base64Encode, getFilename } from "@/utils"
import { For } from "solid-js" import { For } from "solid-js"
import { A } from "@solidjs/router" import { A } from "@solidjs/router"
import { Button } from "@opencode-ai/ui" import { Button } from "@opencode-ai/ui/button"
export default function Home() { export default function Home() {
const sync = useGlobalSync() const sync = useGlobalSync()
+7 -1
View File
@@ -1,10 +1,16 @@
import { Button, Tooltip, DiffChanges, IconButton, Mark, Icon, Collapsible } from "@opencode-ai/ui"
import { createMemo, For, ParentProps, Show } from "solid-js" import { createMemo, For, ParentProps, Show } from "solid-js"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { A, useParams } from "@solidjs/router" import { A, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { base64Encode, getFilename } from "@/utils" 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) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
+32 -313
View File
@@ -1,38 +1,20 @@
import { import { For, onCleanup, onMount, Show, Match, Switch, createResource } from "solid-js"
SelectDialog,
IconButton,
Tabs,
Icon,
Accordion,
Diff,
Collapsible,
DiffChanges,
Message,
Typewriter,
Card,
Code,
Tooltip,
ProgressCircle,
} 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 { useLocal, type LocalFile } from "@/context/local" import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils" import { getDirectory, getFilename } from "@/utils"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { DateTime } from "luxon" 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 { import {
DragDropProvider, DragDropProvider,
DragDropSensors, DragDropSensors,
@@ -45,12 +27,7 @@ import {
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { useSync } from "@/context/sync" 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 { useSession } from "@/context/session"
import { StickyAccordionHeader } from "@/components/sticky-accordion-header"
import { SessionReview } from "@/components/session-review"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
export default function Page() { export default function Page() {
@@ -64,7 +41,6 @@ export default function Page() {
activeDraggable: undefined as string | undefined, activeDraggable: undefined as string | undefined,
}) })
let inputRef!: HTMLDivElement let inputRef!: HTMLDivElement
let messageScrollElement!: HTMLDivElement
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
@@ -357,282 +333,11 @@ export default function Page() {
<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"> <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> <Switch>
<Match when={session.id}> <Match when={session.id}>
<div <SessionTimeline
classList={{ sessionID={session.id!}
"flex-1 min-h-0 pb-20": true, expanded={layout.review.state() === "tab" || !session.diffs().length}
"flex items-start justify-start": layout.review.state() === "pane", classes={{ root: "pb-20", container: "pb-20" }}
}}
>
<Show when={session.messages.user().length > 1}>
{(_) => {
const expanded = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
return (
<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": expanded(),
"mt-3": !expanded(),
}}
>
<For each={session.messages.user()}>
{(message) => {
const working = createMemo(
() => message.id === session.messages.last()?.id && session.working(),
)
const handleClick = () => session.messages.setActive(message.id)
return (
<li
classList={{
"group/li flex items-center self-stretch justify-end": true,
"@7xl:justify-start": expanded(),
}}
>
<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": expanded(),
}}
>
<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": expanded(),
}}
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 [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.id === session.messages.last()?.id && session.working(),
)
const initialCompleted = !(message.id === session.messages.last()?.id && session.working())
const [completed, setCompleted] = createSignal(initialCompleted)
// allowing time for the animations to finish
createEffect(() => {
const title = message.summary?.title
setTimeout(() => setTitled(!!title), 10_000)
})
createEffect(() => {
const completed = !working()
setTimeout(() => setCompleted(completed), 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 && !initialCompleted,
}}
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>
</Match> </Match>
<Match when={true}> <Match when={true}>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch"> <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
@@ -670,7 +375,21 @@ export default function Page() {
"relative grow px-6 py-3 flex-1 min-h-0 border-l border-border-weak-base": true, "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> </div>
</Show> </Show>
</div> </div>
@@ -682,7 +401,7 @@ export default function Page() {
"relative px-6 py-3 flex-1 min-h-0 overflow-hidden": true, "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> </div>
</Tabs.Content> </Tabs.Content>
</Show> </Show>
+1 -1
View File
@@ -1,7 +1,7 @@
import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible" import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible"
import { Icon, IconProps } from "@opencode-ai/ui/icon"
import { splitProps } from "solid-js" import { splitProps } from "solid-js"
import type { ComponentProps, ParentProps } 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 CollapsibleProps extends ComponentProps<typeof KobalteCollapsible> {}
export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {} export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {}
-1
View File
@@ -4,4 +4,3 @@ export {
type CollapsibleTriggerProps, type CollapsibleTriggerProps,
type CollapsibleContentProps, type CollapsibleContentProps,
} from "./collapsible" } from "./collapsible"
export { FileIcon, type FileIconProps } from "./file-icon"
+2 -11
View File
@@ -2,7 +2,6 @@ import { defineConfig } from "vite"
import solidPlugin from "vite-plugin-solid" import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
import path from "path" import path from "path"
import { iconsSpritesheet } from "vite-plugin-icons-spritesheet"
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
@@ -10,18 +9,10 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
plugins: [ plugins: [tailwindcss(), solidPlugin()],
tailwindcss(),
solidPlugin(),
iconsSpritesheet({
withTypes: true,
inputDir: "src/assets/file-icons",
outputDir: "src/ui/file-icons",
formatter: "prettier",
}),
],
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
allowedHosts: true,
port: 3000, port: 3000,
}, },
build: { 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.93",
"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" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The AI coding agent built for the terminal" description = "The AI coding agent built for the terminal"
version = "1.0.78" version = "1.0.93"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/sst/opencode" repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.78/opencode-darwin-arm64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.93/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.78/opencode-darwin-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.93/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.78/opencode-linux-arm64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.93/opencode-linux-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.78/opencode-linux-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.93/opencode-linux-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.78/opencode-windows-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.93/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.78", "version": "1.0.93",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+8
View File
@@ -94,6 +94,14 @@ declare module "sst" {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
} }
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
} }
} }
// cloudflare // 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 .
RUN ./opencode --version
+6 -4
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.0.78", "version": "1.0.93",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -44,6 +44,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1", "@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/mcp": "0.0.8",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
@@ -54,8 +55,9 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.46", "@opencode-ai/util": "workspace:*",
"@opentui/solid": "0.1.46", "@opentui/core": "0.1.47",
"@opentui/solid": "0.1.47",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:", "@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -69,7 +71,7 @@
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
"hono": "catalog:", "hono": "catalog:",
"hono-openapi": "1.1.1", "hono-openapi": "catalog:",
"ignore": "7.0.5", "ignore": "7.0.5",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"minimatch": "10.0.3", "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", filetype: "haskell",
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm", 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], plugins: [solidPlugin],
sourcemap: "external", sourcemap: "external",
compile: { compile: {
autoloadBunfig: false,
autoloadDotenv: false,
target: name.replace(pkg.name, "bun") as any, target: name.replace(pkg.name, "bun") as any,
outfile: `dist/${name}/bin/opencode`, outfile: `dist/${name}/bin/opencode`,
execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`], execArgv: [`--user-agent=opencode/${Script.version}`, "--"],
windows: {}, windows: {},
}, },
entrypoints: ["./src/index.ts", parserWorker, workerPath], entrypoints: ["./src/index.ts", parserWorker, workerPath],
+45 -8
View File
@@ -59,12 +59,16 @@ if (!Script.preview) {
if (!Script.preview) { if (!Script.preview) {
for (const key of Object.keys(binaries)) { for (const key of Object.keys(binaries)) {
if (key.includes("linux")) {
await $`cd dist/${key}/bin && tar -czf ../../${key}.tar.gz *`
} else {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *` await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
} }
}
// Calculate SHA values // Calculate SHA values
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.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.zip | 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 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()) 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')", "conflicts=('opencode')",
"depends=('fzf' 'ripgrep')", "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}')`, `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}')`, `sha256sums_x86_64=('${x64Sha}')`,
"", "",
"package() {", "package() {",
@@ -131,7 +135,34 @@ if (!Script.preview) {
"", "",
"package() {", "package() {",
` cd "opencode-\${pkgver}/packages/opencode"`, ` 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") ].join("\n")
@@ -189,14 +220,14 @@ if (!Script.preview) {
"", "",
" on_linux do", " on_linux do",
" if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?", " 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}"`, ` sha256 "${x64Sha}"`,
" def install", " def install",
' bin.install "opencode"', ' bin.install "opencode"',
" end", " end",
" end", " end",
" if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?", " 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}"`, ` sha256 "${arm64Sha}"`,
" def install", " def install",
' bin.install "opencode"', ' 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 add opencode.rb`
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"` await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push` 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`
} }
+34 -2
View File
@@ -79,16 +79,48 @@ export namespace BunProc {
version, version,
}) })
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, { await BunProc.run(args, {
cwd: Global.Path.cache, cwd: Global.Path.cache,
}).catch((e) => { }).catch(async (error) => {
log.warn("bun install failed", {
pkg,
version,
attempt: count,
total,
error,
})
if (count >= total) {
throw new InstallFailedError( throw new InstallFailedError(
{ pkg, version }, { pkg, version },
{ {
cause: e, 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 parsed.dependencies[pkg] = version
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2)) await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
return mod return mod
+12 -1
View File
@@ -2,10 +2,11 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
import { Clipboard } from "@tui/util/clipboard" import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route" 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 { Installation } from "@/installation"
import { Global } from "@/global" import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk" import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider, useSync } from "@tui/context/sync" import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local" import { LocalProvider, useLocal } from "@tui/context/local"
@@ -293,6 +294,14 @@ function App() {
}, },
category: "System", category: "System",
}, },
{
title: "Connect provider",
value: "provider.connect",
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
category: "System",
},
{ {
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`, title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
value: "theme.switch_mode", value: "theme.switch_mode",
@@ -451,6 +460,7 @@ function App() {
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text> <text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box> </box>
</box> </box>
<Show when={false}>
<box flexDirection="row" flexShrink={0}> <box flexDirection="row" flexShrink={0}>
<text fg={theme.textMuted} paddingRight={1}> <text fg={theme.textMuted} paddingRight={1}>
tab tab
@@ -461,6 +471,7 @@ function App() {
<span> AGENT </span> <span> AGENT </span>
</text> </text>
</box> </box>
</Show>
</box> </box>
</box> </box>
) )
@@ -1,9 +1,7 @@
export const SplitBorder = { export const EmptyBorder = {
border: ["left" as const, "right" as const],
customBorderChars: {
topLeft: "", topLeft: "",
bottomLeft: "", bottomLeft: "",
vertical: "", vertical: "",
topRight: "", topRight: "",
bottomRight: "", bottomRight: "",
horizontal: " ", horizontal: " ",
@@ -12,5 +10,12 @@ export const SplitBorder = {
cross: "", cross: "",
leftT: "", leftT: "",
rightT: "", rightT: "",
}
export const SplitBorder = {
border: ["left" as const, "right" as const],
customBorderChars: {
...EmptyBorder,
vertical: "┃",
}, },
} }
@@ -1,15 +1,10 @@
import { createMemo, createSignal } from "solid-js" import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync" 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 { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog" import { useDialog } from "@tui/ui/dialog"
import { useTheme } from "../context/theme" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
function Free() {
const { theme } = useTheme()
return <span style={{ fg: theme.secondary }}>Free</span>
}
export function DialogModel() { export function DialogModel() {
const local = useLocal() const local = useLocal()
@@ -17,9 +12,16 @@ export function DialogModel() {
const dialog = useDialog() const dialog = useDialog()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>() const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
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(() => { const options = createMemo(() => {
return [ return [
...(!ref()?.filter ...(showRecent()
? local.model.recent().flatMap((item) => { ? local.model.recent().flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)! const provider = sync.data.provider.find((x) => x.id === item.providerID)!
if (!provider) return [] if (!provider) return []
@@ -35,7 +37,17 @@ export function DialogModel() {
title: model.name ?? item.modelID, title: model.name ?? item.modelID,
description: provider.name, description: provider.name,
category: "Recent", 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 },
)
},
}, },
] ]
}) })
@@ -56,28 +68,56 @@ export function DialogModel() {
modelID: model, modelID: model,
}, },
title: info.name ?? model, title: info.name ?? model,
description: provider.name, description: connected() ? provider.name : undefined,
category: provider.name, category: connected() ? provider.name : undefined,
footer: info.cost?.input === 0 && provider.id === "opencode" ? <Free /> : 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), sortBy((x) => x.title),
), ),
), ),
), ),
...(!connected()
? pipe(
providers(),
map((option) => {
return {
...option,
category: "Popular providers",
}
}),
take(6),
)
: []),
] ]
}) })
return ( return (
<DialogSelect <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} ref={setRef}
title="Select model" title="Select model"
current={local.model.current()} current={local.model.current()}
options={options()} 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 // 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 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) return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
// If the filter contains a space, hide the autocomplete
if (val.includes(" ")) {
hide()
return undefined
}
return val
}) })
function insertPart(text: string, part: PromptInfo["parts"][number]) { function insertPart(text: string, part: PromptInfo["parts"][number]) {
@@ -387,17 +379,19 @@ export function Autocomplete(props: {
get visible() { get visible() {
return store.visible return store.visible
}, },
onInput() { onInput(value) {
if (store.visible) { 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() hide()
return 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) { onKeyDown(e: KeyEvent) {
@@ -1,18 +1,8 @@
import { import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
TextAttributes, import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
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 { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme" 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 { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
@@ -29,6 +19,8 @@ import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk" import type { FilePart } from "@opencode-ai/sdk"
import { TuiEvent } from "../../event" import { TuiEvent } from "../../event"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { Shimmer } from "../../ui/shimmer"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@@ -57,7 +49,7 @@ export function Prompt(props: PromptProps) {
const sdk = useSDK() const sdk = useSDK()
const route = useRoute() const route = useRoute()
const sync = useSync() 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 history = usePromptHistory()
const command = useCommandDialog() const command = useCommandDialog()
const renderer = useRenderer() const renderer = useRenderer()
@@ -222,12 +214,17 @@ export function Prompt(props: PromptProps) {
title: "Interrupt session", title: "Interrupt session",
value: "session.interrupt", value: "session.interrupt",
keybind: "session_interrupt", keybind: "session_interrupt",
disabled: status() !== "working", disabled: status().type === "idle",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
if (!props.sessionID) return
if (autocomplete.visible) return if (autocomplete.visible) return
if (!input.focused) 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) setStore("interrupt", store.interrupt + 1)
@@ -425,6 +422,10 @@ export function Prompt(props: PromptProps) {
}, },
body: { body: {
agent: local.agent.current().name, agent: local.agent.current().name,
model: {
providerID: local.model.current().providerID,
modelID: local.model.current().modelID,
},
command: inputText, command: inputText,
}, },
}) })
@@ -538,6 +539,16 @@ export function Prompt(props: PromptProps) {
return 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 ( return (
<> <>
<Autocomplete <Autocomplete
@@ -562,17 +573,22 @@ export function Prompt(props: PromptProps) {
/> />
<box ref={(r) => (anchor = r)}> <box ref={(r) => (anchor = r)}>
<box <box
flexDirection="row" border={["left"]}
{...SplitBorder} borderColor={highlight()}
borderColor={keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border} customBorderChars={{
justifyContent="space-evenly" ...EmptyBorder,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box
paddingLeft={2}
paddingRight={1}
paddingTop={1}
flexShrink={0}
backgroundColor={theme.backgroundElement}
flexGrow={1}
> >
<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}>
<textarea <textarea
placeholder={ placeholder={
props.showPlaceholder props.showPlaceholder
@@ -747,20 +763,87 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.primary} cursorColor={theme.primary}
syntaxStyle={syntax()} 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> </box>
<box backgroundColor={theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box> </Show>
</box>
</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>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
<text flexShrink={0} wrapMode="none" fg={theme.text}> <Show when={status().type !== "idle"} fallback={<text />}>
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "} <box
<span style={{ bold: true }}>{local.model.parsed().model}</span> 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> </text>
<Switch> </Show>
<Match when={status() === "compacting"}> )
<text fg={theme.textMuted}>compacting...</text> })()}
</Match> </box>
<Match when={status() === "working"}> </box>
<box flexDirection="row" gap={1}>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}> <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "} esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}> <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
@@ -768,16 +851,63 @@ export function Prompt(props: PromptProps) {
</span> </span>
</text> </text>
</box> </box>
</Match> </Show>
<Match when={props.hint}>{props.hint!}</Match> <Show when={status().type !== "retry"}>
<Match when={true}> <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}> <text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span> {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text> </text>
</Match> </Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch> </Switch>
</box> </box>
</Show>
</box>
</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 { useToast } from "../ui/toast"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { useArgs } from "./args" import { useArgs } from "./args"
import { RGBA } from "@opentui/core"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
@@ -91,7 +92,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}, },
color(name: string) { color(name: string) {
const agent = agents().find((x) => x.name === name) 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) const index = agents().findIndex((x) => x.name === name)
return colors()[index % colors().length] return colors()[index % colors().length]
}, },
@@ -1,7 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk" import { createOpencodeClient, type Event } from "@opencode-ai/sdk"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js" import { batch, onCleanup } from "solid-js"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
@@ -17,11 +17,44 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
}>() }>()
sdk.event.subscribe().then(async (events) => { sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) { let queue: Event[] = []
console.log("event", event.type) let timer: Timer | undefined
let last = 0
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event) emitter.emit(event.type, event)
} }
}) })
}
for await (const event of events.stream) {
queue.push(event)
const elapsed = Date.now() - last
if (timer) continue
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
continue
}
flush()
}
// Flush any remaining events
if (timer) clearTimeout(timer)
if (queue.length > 0) {
flush()
}
})
onCleanup(() => { onCleanup(() => {
abort.abort() abort.abort()
@@ -12,10 +12,12 @@ import type {
McpStatus, McpStatus,
FormatterStatus, FormatterStatus,
SessionStatus, SessionStatus,
ProviderListResponse,
ProviderAuthMethod,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { Binary } from "@/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot" import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit" import { useExit } from "./exit"
@@ -28,6 +30,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
status: "loading" | "partial" | "complete" status: "loading" | "partial" | "complete"
provider: Provider[] provider: Provider[]
provider_default: Record<string, string> provider_default: Record<string, string>
provider_next: ProviderListResponse
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[] agent: Agent[]
command: Command[] command: Command[]
permission: { permission: {
@@ -56,6 +60,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
} }
formatter: FormatterStatus[] formatter: FormatterStatus[]
}>({ }>({
provider_next: {
all: [],
default: {},
connected: [],
},
provider_auth: {},
config: {}, config: {},
status: "loading", status: "loading",
agent: [], agent: [],
@@ -232,20 +242,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const exit = useExit() const exit = useExit()
onMount(() => { async function bootstrap() {
// blocking // blocking
Promise.all([ await Promise.all([
sdk.client.config.providers({ throwOnError: true }).then((x) => { sdk.client.config.providers({ throwOnError: true }).then((x) => {
batch(() => { batch(() => {
setStore("provider", x.data!.providers) setStore("provider", x.data!.providers)
setStore("provider_default", x.data!.default) setStore("provider_default", x.data!.default)
}) })
}), }),
sdk.client.provider.list({ throwOnError: true }).then((x) => {
batch(() => {
setStore("provider_next", x.data!)
})
}),
sdk.client.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])), sdk.client.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)), sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)),
]) ])
.then(() => { .then(() => {
setStore("status", "partial") if (store.status !== "complete") setStore("status", "partial")
// non-blocking // non-blocking
Promise.all([ Promise.all([
sdk.client.session.list().then((x) => sdk.client.session.list().then((x) =>
@@ -259,6 +274,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)), sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)), sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
sdk.client.session.status().then((x) => setStore("session_status", x.data!)), sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
]).then(() => { ]).then(() => {
setStore("status", "complete") setStore("status", "complete")
}) })
@@ -266,6 +282,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.catch(async (e) => { .catch(async (e) => {
await exit(e) await exit(e)
}) })
}
onMount(() => {
bootstrap()
}) })
const result = { const result = {
@@ -320,6 +340,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
console.log("synced in " + (Date.now() - now), sessionID) console.log("synced in " + (Date.now() - now), sessionID)
}, },
}, },
bootstrap,
} }
return result return result
}, },
@@ -189,6 +189,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}) })
const syntax = createMemo(() => generateSyntax(values())) const syntax = createMemo(() => generateSyntax(values()))
const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
return { return {
theme: new Proxy(values(), { theme: new Proxy(values(), {
@@ -204,6 +205,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return store.themes return store.themes
}, },
syntax, syntax,
subtleSyntax,
mode() { mode() {
return store.mode return store.mode
}, },
@@ -427,7 +429,35 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
} }
function generateSyntax(theme: Theme) { function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme([ return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
function generateSubtleSyntax(theme: Theme) {
const rules = getSyntaxRules(theme)
return SyntaxStyle.fromTheme(
rules.map((rule) => {
if (rule.style.foreground) {
const fg = rule.style.foreground
return {
...rule,
style: {
...rule.style,
foreground: RGBA.fromInts(
Math.round(fg.r * 255),
Math.round(fg.g * 255),
Math.round(fg.b * 255),
Math.round(0.6 * 255),
),
},
}
}
return rule
}),
)
}
function getSyntaxRules(theme: Theme) {
return [
{ {
scope: ["prompt"], scope: ["prompt"],
style: { style: {
@@ -921,5 +951,5 @@ function generateSyntax(theme: Theme) {
foreground: theme.textMuted, foreground: theme.textMuted,
}, },
}, },
]) ]
} }
@@ -54,6 +54,7 @@ import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history" import type { PromptInfo } from "../../component/prompt/history"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { DialogTimeline } from "./dialog-timeline" import { DialogTimeline } from "./dialog-timeline"
import { DialogSessionRename } from "../../component/dialog-session-rename" import { DialogSessionRename } from "../../component/dialog-session-rename"
import { Sidebar } from "./sidebar" import { Sidebar } from "./sidebar"
@@ -105,11 +106,6 @@ export function Session() {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
}) })
const lastUserMessage = createMemo(() => {
const p = pending()
return messages().findLast((x) => x.role === "user" && (!p || x.id < p)) as UserMessage
})
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto")) const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
const [conceal, setConceal] = createSignal(true) const [conceal, setConceal] = createSignal(true)
@@ -146,9 +142,30 @@ export function Session() {
}) })
const toast = useToast() const toast = useToast()
const sdk = useSDK() const sdk = useSDK()
// Auto-navigate to whichever session currently needs permission input
createEffect(() => {
const currentSession = session()
if (!currentSession) return
const currentPermissions = permissions()
let targetID = currentPermissions.length > 0 ? currentSession.id : undefined
if (!targetID) {
const child = sync.data.session.find(
(x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0,
)
if (child) targetID = child.id
}
if (targetID && targetID !== currentSession.id) {
navigate({
type: "session",
sessionID: targetID,
})
}
})
let scroll: ScrollBoxRenderable let scroll: ScrollBoxRenderable
let prompt: PromptRef let prompt: PromptRef
const keybind = useKeybind() const keybind = useKeybind()
@@ -583,12 +600,17 @@ export function Session() {
transcript += `---\n\n` transcript += `---\n\n`
} }
// Save to file in data directory // Prompt for optional filename
const exportDir = path.join(Global.Path.data, "exports") const customFilename = await DialogPrompt.show(dialog, "Export filename", {
await fs.mkdir(exportDir, { recursive: true }) value: `session-${sessionData.id.slice(0, 8)}.md`,
})
const timestamp = new Date().toISOString().replace(/[:.]/g, "-") // Cancel if user pressed escape
const filename = `session-${sessionData.id.slice(0, 8)}-${timestamp}.md` if (customFilename === null) return
// Save to file in current working directory
const exportDir = process.cwd()
const filename = customFilename.trim()
const filepath = path.join(exportDir, filename) const filepath = path.join(exportDir, filename)
await Bun.write(filepath, transcript) await Bun.write(filepath, transcript)
@@ -631,15 +653,11 @@ export function Session() {
}, },
]) ])
const revert = createMemo(() => { const revertInfo = createMemo(() => session()?.revert)
const s = session() const revertMessageID = createMemo(() => revertInfo()?.messageID)
if (!s) return
const messageID = s.revert?.messageID
if (!messageID) return
const reverted = messages().filter((x) => x.id >= messageID && x.role === "user")
const diffFiles = (() => { const revertDiffFiles = createMemo(() => {
const diffText = s.revert?.diff || "" const diffText = revertInfo()?.diff ?? ""
if (!diffText) return [] if (!diffText) return []
try { try {
@@ -662,13 +680,23 @@ export function Session() {
} catch (error) { } catch (error) {
return [] return []
} }
})() })
const revertRevertedMessages = createMemo(() => {
const messageID = revertMessageID()
if (!messageID) return []
return messages().filter((x) => x.id >= messageID && x.role === "user")
})
const revert = createMemo(() => {
const info = revertInfo()
if (!info) return
if (!info.messageID) return
return { return {
messageID, messageID: info.messageID,
reverted, reverted: revertRevertedMessages(),
diff: s.revert!.diff, diff: info.diff,
diffFiles, diffFiles: revertDiffFiles(),
} }
}) })
@@ -880,6 +908,12 @@ function UserMessage(props: {
<Show when={text()}> <Show when={text()}>
<box <box
id={props.message.id} id={props.message.id}
border={["left"]}
borderColor={color()}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
>
<box
onMouseOver={() => { onMouseOver={() => {
setHover(true) setHover(true)
}} }}
@@ -887,14 +921,10 @@ function UserMessage(props: {
setHover(false) setHover(false)
}} }}
onMouseUp={props.onMouseUp} onMouseUp={props.onMouseUp}
border={["left"]}
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
paddingLeft={2} paddingLeft={1}
marginTop={props.index === 0 ? 0 : 1}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={color()}
flexShrink={0} flexShrink={0}
> >
<text fg={theme.text}>{text()?.text}</text> <text fg={theme.text}>{text()?.text}</text>
@@ -917,16 +947,17 @@ function UserMessage(props: {
</For> </For>
</box> </box>
</Show> </Show>
<text fg={theme.text}> <text fg={theme.textMuted}>
{sync.data.config.username ?? "You"}{" "} {sync.data.config.username ?? "You"}{" "}
<Show <Show
when={queued()} when={queued()}
fallback={<span style={{ fg: theme.textMuted }}>({Locale.time(props.message.time.created)})</span>} fallback={<span style={{ fg: theme.textMuted }}>{Locale.time(props.message.time.created)}</span>}
> >
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span> <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
</Show> </Show>
</text> </text>
</box> </box>
</box>
</Show> </Show>
<Show when={compaction()}> <Show when={compaction()}>
<box <box
@@ -982,7 +1013,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<text fg={theme.textMuted}>{props.message.error?.data.message}</text> <text fg={theme.textMuted}>{props.message.error?.data.message}</text>
</box> </box>
</Show> </Show>
<Show when={props.last && status().type !== "idle"}> <Switch>
<Match when={props.last && status().type !== "idle" && false}>
<box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}> <box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}>
<text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text> <text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
<Shimmer text={props.message.modelID} color={theme.text} /> <Shimmer text={props.message.modelID} color={theme.text} />
@@ -1021,11 +1053,12 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
) )
})()} })()}
</box> </box>
</Show> </Match>
<Show <Match
when={ when={
props.message.time.completed && (props.message.time.completed &&
props.parts.some((item) => item.type === "step-finish" && item.reason !== "tool-calls") props.parts.some((item) => item.type === "step-finish" && item.reason !== "tool-calls")) ||
props.last
} }
> >
<box paddingLeft={3}> <box paddingLeft={3}>
@@ -1034,7 +1067,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span> <span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
</text> </text>
</box> </box>
</Show> </Match>
</Switch>
</> </>
) )
} }
@@ -1046,7 +1080,7 @@ const PART_MAPPING = {
} }
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme, syntax } = useTheme() const { theme, subtleSyntax } = useTheme()
const ctx = use() const ctx = use()
const content = createMemo(() => props.part.text.trim()) const content = createMemo(() => props.part.text.trim())
return ( return (
@@ -1064,10 +1098,10 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
filetype="markdown" filetype="markdown"
drawUnstyledText={false} drawUnstyledText={false}
streaming={true} streaming={true}
syntaxStyle={syntax()} syntaxStyle={subtleSyntax()}
content={"_Thinking:_ " + content()} content={"_Thinking:_ " + content()}
conceal={ctx.conceal()} conceal={ctx.conceal()}
fg={theme.text} fg={theme.textMuted}
/> />
</box> </box>
</Show> </Show>
@@ -1283,7 +1317,10 @@ ToolRegistry.register<typeof WriteTool>({
container: "block", container: "block",
render(props) { render(props) {
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const lines = createMemo(() => props.input.content?.split("\n") ?? [], [] as string[]) const lines = createMemo(
() => (typeof props.input.content === "string" ? props.input.content.split("\n") : []),
[] as string[],
)
const code = createMemo(() => { const code = createMemo(() => {
if (!props.input.content) return "" if (!props.input.content) return ""
const text = props.input.content const text = props.input.content
@@ -1,11 +1,13 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core" import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { onMount } from "solid-js" import { onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
export type DialogPromptProps = { export type DialogPromptProps = {
title: string title: string
description?: () => JSX.Element
placeholder?: string
value?: string value?: string
onConfirm?: (value: string) => void onConfirm?: (value: string) => void
onCancel?: () => void onCancel?: () => void
@@ -19,12 +21,11 @@ export function DialogPrompt(props: DialogPromptProps) {
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.name === "return") { if (evt.name === "return") {
props.onConfirm?.(textarea.plainText) props.onConfirm?.(textarea.plainText)
dialog.clear()
} }
}) })
onMount(() => { onMount(() => {
dialog.setSize("large") dialog.setSize("medium")
setTimeout(() => { setTimeout(() => {
textarea.focus() textarea.focus()
}, 1) }, 1)
@@ -37,35 +38,36 @@ export function DialogPrompt(props: DialogPromptProps) {
<text attributes={TextAttributes.BOLD}>{props.title}</text> <text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text> <text fg={theme.textMuted}>esc</text>
</box> </box>
<box> <box gap={1}>
{props.description}
<textarea <textarea
onSubmit={() => { onSubmit={() => {
props.onConfirm?.(textarea.plainText) props.onConfirm?.(textarea.plainText)
dialog.clear()
}} }}
height={3}
keyBindings={[{ name: "return", action: "submit" }]} keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)} ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value} initialValue={props.value}
placeholder="Enter text" placeholder={props.placeholder ?? "Enter text"}
/> />
</box> </box>
<box paddingBottom={1}> <box paddingBottom={1} gap={1} flexDirection="row">
<text fg={theme.textMuted}>Press enter to confirm, esc to cancel</text> <text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>cancel</span>
</text>
</box> </box>
</box> </box>
) )
} }
DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => { DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
return new Promise<string | null>((resolve) => { return new Promise<string | null>((resolve) => {
dialog.replace( dialog.replace(
() => ( () => (
<DialogPrompt <DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
title={title}
value={value}
onConfirm={(value) => resolve(value)}
onCancel={() => resolve(null)}
/>
), ),
() => resolve(null), () => resolve(null),
) )
@@ -1,7 +1,7 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { batch, createEffect, createMemo, For, Show } from "solid-js" import { batch, createEffect, createMemo, For, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
@@ -157,7 +157,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return ( return (
<box gap={1}> <box gap={1}>
<box paddingLeft={3} paddingRight={2}> <box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}> <text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title} {props.title}
@@ -184,8 +184,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</box> </box>
</box> </box>
<scrollbox <scrollbox
paddingLeft={2} paddingLeft={1}
paddingRight={2} paddingRight={1}
scrollbarOptions={{ visible: false }} scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (scroll = r)} ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()} maxHeight={height()}
@@ -194,7 +194,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
{([category, options], index) => ( {([category, options], index) => (
<> <>
<Show when={category}> <Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}> <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}> <text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category} {category}
</text> </text>
@@ -203,6 +203,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<For each={options}> <For each={options}>
{(option) => { {(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
const current = createMemo(() => isDeepEqual(option.value, props.current))
return ( return (
<box <box
id={JSON.stringify(option.value)} id={JSON.stringify(option.value)}
@@ -217,8 +218,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
moveTo(index) moveTo(index)
}} }}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1} paddingLeft={current() ? 1 : 3}
paddingRight={1} paddingRight={3}
gap={1} gap={1}
> >
<Option <Option
@@ -226,7 +227,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
footer={option.footer} footer={option.footer}
description={option.description !== category ? option.description : undefined} description={option.description !== category ? option.description : undefined}
active={active()} active={active()}
current={isDeepEqual(option.value, props.current)} current={current()}
/> />
</box> </box>
) )
@@ -236,12 +237,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)} )}
</For> </For>
</scrollbox> </scrollbox>
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1} gap={1}> <box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}>
<For each={props.keybind ?? []}> <For each={props.keybind ?? []}>
{(item) => ( {(item) => (
<text> <text>
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span> <span style={{ fg: theme.text }}>
<span style={{ fg: theme.textMuted }}> {item.title}</span> <b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text> </text>
)} )}
</For> </For>
@@ -268,7 +271,7 @@ function Option(props: {
fg={props.active ? theme.background : props.current ? theme.primary : theme.text} fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
marginRight={0.5} marginRight={0.5}
> >
</text> </text>
</Show> </Show>
<text <text
@@ -277,6 +280,7 @@ function Option(props: {
attributes={props.active ? TextAttributes.BOLD : undefined} attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden" overflow="hidden"
wrapMode="none" wrapMode="none"
paddingLeft={3}
> >
{Locale.truncate(props.title, 62)} {Locale.truncate(props.title, 62)}
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span> <span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
@@ -3,6 +3,8 @@ import { batch, createContext, Show, useContext, type JSX, type ParentProps } fr
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core" import { Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "./toast"
export function Dialog( export function Dialog(
props: ParentProps<{ props: ParentProps<{
@@ -12,10 +14,12 @@ export function Dialog(
) { ) {
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const { theme } = useTheme() const { theme } = useTheme()
const renderer = useRenderer()
return ( return (
<box <box
onMouseUp={async () => { onMouseUp={async () => {
if (renderer.getSelection()) return
props.onClose?.() props.onClose?.()
}} }}
width={dimensions().width} width={dimensions().width}
@@ -29,6 +33,7 @@ export function Dialog(
> >
<box <box
onMouseUp={async (e) => { onMouseUp={async (e) => {
if (renderer.getSelection()) return
e.stopPropagation() e.stopPropagation()
}} }}
width={props.size === "large" ? 80 : 60} width={props.size === "large" ? 80 : 60}
@@ -124,10 +129,28 @@ const ctx = createContext<DialogContext>()
export function DialogProvider(props: ParentProps) { export function DialogProvider(props: ParentProps) {
const value = init() const value = init()
const renderer = useRenderer()
const toast = useToast()
return ( return (
<ctx.Provider value={value}> <ctx.Provider value={value}>
{props.children} {props.children}
<box position="absolute"> <box
position="absolute"
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
}}
>
<Show when={value.stack.length}> <Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}> <Dialog onClose={() => value.clear()} size={value.size}>
{value.stack.at(-1)!.element} {value.stack.at(-1)!.element}
@@ -23,7 +23,6 @@ export function Toast() {
alignItems="flex-start" alignItems="flex-start"
top={2} top={2}
right={2} right={2}
width={Math.min(60, dimensions().width - 6)}
maxWidth={Math.min(60, dimensions().width - 6)} maxWidth={Math.min(60, dimensions().width - 6)}
paddingLeft={2} paddingLeft={2}
paddingRight={2} paddingRight={2}
@@ -39,7 +38,9 @@ export function Toast() {
{current().title} {current().title}
</text> </text>
</Show> </Show>
<text fg={theme.text}>{current().message}</text> <text fg={theme.text} wrapMode="word" width="100%">
{current().message}
</text>
</box> </box>
)} )}
</Show> </Show>
@@ -6,7 +6,7 @@ import { CliRenderer } from "@opentui/core"
export namespace Editor { export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> { export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["EDITOR"] const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return if (!editor) return
const filepath = join(tmpdir(), `${Date.now()}.md`) const filepath = join(tmpdir(), `${Date.now()}.md`)
+14 -1
View File
@@ -1,18 +1,31 @@
import { ConfigMarkdown } from "@/config/markdown" import { ConfigMarkdown } from "@/config/markdown"
import { Config } from "../config/config" import { Config } from "../config/config"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { Provider } from "../provider/provider"
import { UI } from "./ui" import { UI } from "./ui"
export function FormatError(input: unknown) { export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input)) if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (Provider.ModelNotFoundError.isInstance(input)) {
const { providerID, modelID, suggestions } = input.data
return [
`Model not found: ${providerID}/${modelID}`,
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
].join("\n")
}
if (Provider.InitError.isInstance(input)) {
return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
}
if (Config.JsonError.isInstance(input)) { if (Config.JsonError.isInstance(input)) {
return ( return (
`Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "") `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
) )
} }
if (Config.ConfigDirectoryTypoError.isInstance(input)) { if (Config.ConfigDirectoryTypoError.isInstance(input)) {
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Use "${input.data.suggestion}" instead. This is a common typo.` return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
} }
if (ConfigMarkdown.FrontmatterError.isInstance(input)) { if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}` return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
+24 -6
View File
@@ -428,8 +428,8 @@ export namespace Config {
input_newline: z.string().optional().default("shift+return,ctrl+j").describe("Insert newline in input"), input_newline: z.string().optional().default("shift+return,ctrl+j").describe("Insert newline in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"), history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Next history item"), history_next: z.string().optional().default("down").describe("Next history item"),
session_child_cycle: z.string().optional().default("ctrl+right").describe("Next child session"), session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("ctrl+left").describe("Previous child session"), session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
}) })
.strict() .strict()
.meta({ .meta({
@@ -480,6 +480,10 @@ export namespace Config {
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"), .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"), autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"), disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
enabled_providers: z
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: z small_model: z
.string() .string()
@@ -511,6 +515,8 @@ export namespace Config {
z.string(), z.string(),
ModelsDev.Provider.partial() ModelsDev.Provider.partial()
.extend({ .extend({
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
models: z.record(z.string(), ModelsDev.Model.partial()).optional(), models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
options: z options: z
.object({ .object({
@@ -542,7 +548,9 @@ export namespace Config {
.describe("Custom provider configurations and model overrides"), .describe("Custom provider configurations and model overrides"),
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"), mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
formatter: z formatter: z
.record( .union([
z.literal(false),
z.record(
z.string(), z.string(),
z.object({ z.object({
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
@@ -550,10 +558,13 @@ export namespace Config {
environment: z.record(z.string(), z.string()).optional(), environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(), extensions: z.array(z.string()).optional(),
}), }),
) ),
])
.optional(), .optional(),
lsp: z lsp: z
.record( .union([
z.literal(false),
z.record(
z.string(), z.string(),
z.union([ z.union([
z.object({ z.object({
@@ -567,11 +578,13 @@ export namespace Config {
initialization: z.record(z.string(), z.any()).optional(), initialization: z.record(z.string(), z.any()).optional(),
}), }),
]), ]),
) ),
])
.optional() .optional()
.refine( .refine(
(data) => { (data) => {
if (!data) return true if (!data) return true
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((s) => s.id)) const serverIds = new Set(Object.values(LSPServer).map((s) => s.id))
return Object.entries(data).every(([id, config]) => { return Object.entries(data).every(([id, config]) => {
@@ -596,6 +609,11 @@ export namespace Config {
}) })
.optional(), .optional(),
tools: z.record(z.string(), z.boolean()).optional(), tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z
.object({
url: z.string().optional().describe("Enterprise URL"),
})
.optional(),
experimental: z experimental: z
.object({ .object({
hook: z hook: z
+2
View File
@@ -11,6 +11,8 @@ export namespace Flag {
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS") export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT") export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export const OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH =
process.env["OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH"]
// Experimental // Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
+8
View File
@@ -28,6 +28,14 @@ export namespace Format {
const cfg = await Config.get() const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {} const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
enabled,
formatters,
}
}
for (const item of Object.values(Formatter)) { for (const item of Object.values(Formatter)) {
formatters[item.name] = item formatters[item.name] = item
} }
+12 -1
View File
@@ -62,10 +62,21 @@ export namespace LSP {
async () => { async () => {
const clients: LSPClient.Info[] = [] const clients: LSPClient.Info[] = []
const servers: Record<string, LSPServer.Info> = {} const servers: Record<string, LSPServer.Info> = {}
const cfg = await Config.get()
if (cfg.lsp === false) {
log.info("all LSPs are disabled")
return {
broken: new Set<string>(),
servers,
clients,
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
}
}
for (const server of Object.values(LSPServer)) { for (const server of Object.values(LSPServer)) {
servers[server.id] = server servers[server.id] = server
} }
const cfg = await Config.get()
for (const [name, item] of Object.entries(cfg.lsp ?? {})) { for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
const existing = servers[name] const existing = servers[name]
if (item.disabled) { if (item.disabled) {
+96 -8
View File
@@ -250,12 +250,12 @@ export namespace LSPServer {
}, },
} }
export const RubyLsp: Info = { export const Rubocop: Info = {
id: "ruby-lsp", id: "ruby-lsp",
root: NearestRoot(["Gemfile"]), root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) { async spawn(root) {
let bin = Bun.which("ruby-lsp", { let bin = Bun.which("rubocop", {
PATH: process.env["PATH"] + ":" + Global.Path.bin, PATH: process.env["PATH"] + ":" + Global.Path.bin,
}) })
if (!bin) { if (!bin) {
@@ -266,25 +266,25 @@ export namespace LSPServer {
return return
} }
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing ruby-lsp") log.info("installing rubocop")
const proc = Bun.spawn({ const proc = Bun.spawn({
cmd: ["gem", "install", "ruby-lsp", "--bindir", Global.Path.bin], cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin],
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
stdin: "pipe", stdin: "pipe",
}) })
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) { if (exit !== 0) {
log.error("Failed to install ruby-lsp") log.error("Failed to install rubocop")
return return
} }
bin = path.join(Global.Path.bin, "ruby-lsp" + (process.platform === "win32" ? ".exe" : "")) bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed ruby-lsp`, { log.info(`installed rubocop`, {
bin, bin,
}) })
} }
return { return {
process: spawn(bin!, ["--stdio"], { process: spawn(bin!, ["--lsp"], {
cwd: root, cwd: root,
}), }),
} }
@@ -945,6 +945,54 @@ export namespace LSPServer {
}, },
} }
export const YamlLS: Info = {
id: "yaml-ls",
extensions: [".yaml", ".yml"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Bun.file(js).exists()
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
}
},
}
export const LuaLS: Info = { export const LuaLS: Info = {
id: "lua-ls", id: "lua-ls",
root: NearestRoot([ root: NearestRoot([
@@ -1077,4 +1125,44 @@ export namespace LSPServer {
} }
}, },
} }
export const PHPIntelephense: Info = {
id: "php intelephense",
extensions: [".php"],
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
async spawn(root) {
let binary = Bun.which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Bun.file(js).exists())) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
initialization: {},
}
},
}
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { experimental_createMCPClient, type Tool } from "ai" import { type Tool } from "ai"
import { experimental_createMCPClient } from "@ai-sdk/mcp"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"

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