Compare commits

..

150 Commits

Author SHA1 Message Date
opencode 835fa9fb81 release: v0.15.9 2025-10-20 21:16:57 +00:00
Aiden Cline 96ae6d51aa ignore: link labels 2025-10-20 16:02:23 -05:00
Frank 075ef0fa34 wip: share 2025-10-20 16:17:51 -04:00
Aiden Cline 89b72e4442 fix: bash tool permission merges (#3302) 2025-10-20 15:15:14 -05:00
Frank 7a7b3c6315 wip: zen 2025-10-20 13:22:43 -04:00
Rui Ferrão 3d48c14d29 docs: clarify the effects of the subtask command option with primary … (#3299) 2025-10-20 12:16:09 -05:00
GitHub Action bfa79ed44b ignore: update download stats 2025-10-20 2025-10-20 12:04:59 +00:00
Hieu Nguyen 4d8268c818 feat: support astro lsp (#3242) 2025-10-19 22:49:06 -05:00
Squibid 95d413bec6 theme: add mellow theme (#3198) 2025-10-19 22:34:04 -05:00
Joscha Götzer 1cb5a70382 feat(theme): Add Night Owl theme (#3269) 2025-10-19 22:30:04 -05:00
Walter 6adc16ca8a fix(tui): Allow modals to handle ESC key before force closing (#3279) 2025-10-19 22:16:54 -05:00
Zeldris 10ebe9ae09 docs: add scoop & winget to readme (#3215) 2025-10-19 21:50:51 -05:00
Aiden Cline 43a07c6aca hide invalid option 2025-10-19 20:51:33 -05:00
Aiden Cline 5d3a88f34f fix: snapshot undo bug (#3290) 2025-10-19 18:42:34 -05:00
Ivan Uhalin e47edfffe4 docs: fix Grep by Vercel link in documentation (#3280) 2025-10-19 11:51:57 -05:00
GitHub Action 141097fc73 ignore: update download stats 2025-10-19 2025-10-19 12:04:24 +00:00
Aiden Cline c8898463a7 ignore: adjust template 2025-10-18 23:45:01 -05:00
Aiden Cline 1c7bd6365e replace all $ARGUMENTS (#3276) 2025-10-18 23:37:17 -05:00
Aiden Cline 290d15a80f ignore: add version to bug template 2025-10-18 14:56:06 -05:00
Aiden Cline 233a018fe5 docs: contributing.md (#3248)
Co-authored-by: Jay <air@live.ca>
2025-10-18 14:10:14 -05:00
opencode d69beec087 release: v0.15.8 2025-10-18 18:36:52 +00:00
Aiden Cline 1f869bccc1 make compact interruptable (#3251) 2025-10-18 11:49:29 -05:00
GitHub Action 8da8c9e78c ignore: update download stats 2025-10-18 2025-10-18 12:04:14 +00:00
Adam 335d833655 wip: desktop work 2025-10-17 15:22:11 -05:00
Haris Gušić 1dba01e057 Improve typo directory error (#3247) 2025-10-17 14:34:37 -05:00
Jay V a3de43f3de ignore: clarify that workspaces are free for teams during beta 2025-10-17 15:19:19 -04:00
Jay V 22ad4f5365 ignore: help users discover that teams feature is free during beta 2025-10-17 14:33:21 -04:00
Jay V 5bfbec60b5 docs: clarify team collaboration features and pricing 2025-10-17 13:58:10 -04:00
Adam cc18b58ff9 wip: desktop work 2025-10-17 12:07:58 -05:00
Adam 887a819f24 wip: desktop work 2025-10-17 12:06:36 -05:00
mgrachten fe8b3a2515 set 755 permissions (#3237) 2025-10-17 10:42:42 -05:00
GitHub Action 86079353ef ignore: update download stats 2025-10-17 2025-10-17 12:04:43 +00:00
Frank b7c8690414 wip: zen 2025-10-17 02:23:45 -04:00
Frank c25b9bf65a wip: zen 2025-10-17 01:15:25 -04:00
Matt Gillard ddb2e6957c added AU inference for bedrock haiku 4.5 (#3206)
Co-authored-by: Matt Gillard <matt-github@gillard.biz>
2025-10-16 23:48:41 -05:00
Frank a590b32a10 wip: zen 2025-10-17 00:26:00 -04:00
Frank 5f7bba11fd wip: zen 2025-10-16 23:43:49 -04:00
Frank 4663ea5faa wip: zen 2025-10-16 23:19:25 -04:00
Frank dd581e8577 wip: zen 2025-10-17 03:17:07 +00:00
opencode bad01d76de release: v0.15.7 2025-10-17 03:17:07 +00:00
Aiden Cline d69366b00c Revert "try to avoid persisting empty thinking/text blocks"
This reverts commit d8a15e7bc9.
2025-10-16 22:04:55 -05:00
Frank 1947580b08 wip: zen 2025-10-16 22:28:34 -04:00
Frank ca9b13e8a2 wip: zen 2025-10-16 22:28:09 -04:00
Hieu Nguyen 92d9a0ec61 feat: deno lsp (#3210)
Co-authored-by: hiunguynx <hieu.nm1@teko.vn>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2025-10-16 18:08:36 -05:00
opencode 2be9ed2590 release: v0.15.6 2025-10-16 23:02:59 +00:00
Aiden Cline 25861f6d0d sync 2025-10-16 17:49:02 -05:00
Aiden Cline b24f4e3d2c fix: timeout option (#3229) 2025-10-16 17:47:41 -05:00
Frank 729ad1cb75 wip: zen 2025-10-16 17:50:32 -04:00
Haris Gušić fb4105a46c docs: fix Ctrl+K styling in documentation (#3021) 2025-10-16 15:30:28 -05:00
Frank 7abc3e9794 ci: fix 2025-10-16 16:04:25 -04:00
Frank 88fef05923 wip: zen 2025-10-16 15:58:51 -04:00
GitHub Action 8552f3555e chore: format code 2025-10-16 19:55:30 +00:00
Adam 47d9e01765 wip: css/ui and desktop work 2025-10-16 14:53:44 -05:00
Aiden Cline fc18fc8a08 fix: bash hangs & orphans (#3225) 2025-10-16 14:39:36 -05:00
Dax Raad 7474788778 ci: fix 2025-10-16 15:36:12 -04:00
GitHub Action 26d0d20e4d chore: format code 2025-10-16 19:08:21 +00:00
Adam 20229f147b wip: css/ui and desktop work 2025-10-16 14:07:37 -05:00
Aiden Cline 149cb6a9ec fix: connection closed 2025-10-16 14:00:07 -05:00
Frank 7ec5e49e19 zen: support stripe link 2025-10-16 14:59:46 -04:00
Aiden Cline 1c1380d3c8 adjust action 2025-10-16 13:15:27 -05:00
Adam 10680f0cf0 wip: css/ui work 2025-10-16 18:06:59 +00:00
opencode 2517b22552 release: v0.15.5 2025-10-16 18:06:59 +00:00
Aiden Cline 64617c113a ignore: tweak permissions 2025-10-16 12:48:39 -05:00
Aiden Cline 860c6338fc fix: github action (#3223) 2025-10-16 12:33:33 -05:00
Aiden Cline 4a7551e87b ci: fix changelog generation 2025-10-16 11:29:58 -05:00
Moisès Macià 285cc4b9fd docs: fix misspelled word (#3211) 2025-10-16 10:04:11 -05:00
Dax Raad d8a15e7bc9 try to avoid persisting empty thinking/text blocks 2025-10-16 10:54:10 -04:00
opencode 542b9fa342 release: v0.15.4 2025-10-16 14:53:32 +00:00
GitHub Action 9159afb54b ignore: update download stats 2025-10-16 2025-10-16 12:04:48 +00:00
seridescent 536934548a fix: use ai-sdk openai chat language model instead of completion language model (#3204) 2025-10-16 00:59:49 -05:00
Aiden Cline 1c59530115 Revert "fix: Text content blocks must contain non-whitespace text" (#3200) 2025-10-15 20:02:17 -05:00
Dax Raad ab8471a7ff core: filter out alpha status models from provider list 2025-10-15 20:12:37 -04:00
Dax Raad 4c674b075b ci: stuff 2025-10-15 19:59:46 -04:00
Dax Raad ba8a4c5e9f snapshot publish everything 2025-10-15 19:53:14 -04:00
Dax Raad 790fe72f39 sync 2025-10-15 19:48:57 -04:00
Aiden Cline 2d2d4641cb ignore: update readme 2025-10-15 16:06:11 -05:00
Aiden Cline d3caa55c10 Revert "fix: spawns hanging (#3192)"
This reverts commit 278ffb9a4e.
2025-10-15 15:20:14 -05:00
Fabian Kukuck ca534a36e5 feat: make compact feature use streaming API (#3079)
Co-authored-by: fku <fabian.kukuck@ipt.ch>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-10-15 13:44:16 -05:00
Aiden Cline 278ffb9a4e fix: spawns hanging (#3192) 2025-10-15 13:01:54 -05:00
Aiden Cline b2ff4be4c6 fix: Text content blocks must contain non-whitespace text (#3194) 2025-10-15 13:00:26 -05:00
Frank 2267ce2511 zen: support haiku 4.5 2025-10-15 13:53:00 -04:00
Matt Gillard e29d1d339c updated bedrock provider for the new Australian sonnet 4.5 cross region inference (#3050)
Co-authored-by: Matt Gillard <matt-github@gillard.biz>
2025-10-15 11:09:22 -05:00
Haris Gušić 92bc78a2d3 Improve http error codes (#3186) 2025-10-15 10:53:09 -05:00
GitHub Action 1ba5535460 ignore: update download stats 2025-10-15 2025-10-15 12:04:51 +00:00
Frank 7fa9a73bf0 wip: zen 2025-10-15 02:55:01 -04:00
Aiden Cline b3fcc9a81d tweak: consolidate session lock logic (#3185) 2025-10-15 01:12:51 -05:00
Frank e8751d976e wip: zen 2025-10-15 01:07:57 -04:00
Frank 43c9702aa7 wip: zen 2025-10-15 00:02:38 -04:00
Frank ae609be710 wip: zen 2025-10-14 23:45:06 -04:00
Frank 86ee36f562 wip: zen 2025-10-14 23:38:21 -04:00
Frank 0657f09139 wip: zen 2025-10-14 23:04:41 -04:00
Frank 182949dee4 wip: zen 2025-10-14 23:03:13 -04:00
Dax Raad 83655a3b09 ci: run typecheck before tests to catch type errors early 2025-10-14 18:36:03 -04:00
Dax Raad 62e5f4b154 try tsgo 2025-10-14 18:30:32 -04:00
Jay V ea926f0e1a ignore: prompt 2025-10-14 17:45:10 -04:00
Jay V 6191232d5f web: colocate copy button styles with components that use them 2025-10-14 17:41:17 -04:00
Frank 95f4ce86d6 ci: fix 2025-10-14 17:22:29 -04:00
Frank 5999aefde3 wip: zen 2025-10-14 17:18:39 -04:00
Frank babe3a0f40 wip: zen 2025-10-14 17:13:21 -04:00
Frank 29b95dee53 wip: zen 2025-10-14 17:06:49 -04:00
Frank ef9a1e911e wip: zen 2025-10-14 17:06:49 -04:00
Jay V 7eddaa806d docs: improve MCP server configuration guidance with examples and caveats 2025-10-14 16:43:59 -04:00
Dax Raad d07e79e6ad ci: channels 2025-10-14 15:09:18 -04:00
Dax Raad f17a7cde8d sync 2025-10-14 14:57:34 -04:00
GitHub Action 6d446c2a03 chore: format code 2025-10-14 18:56:54 +00:00
Dax Raad 61f6091de1 ci: test 2025-10-14 14:56:21 -04:00
Dax Raad 289783f627 ci: version stuff 2025-10-14 14:52:05 -04:00
Dax Raad 4c464cf4c0 ci: fix 2025-10-14 18:44:22 +00:00
opencode 83be5b0171 release: v0.15.3 2025-10-14 18:44:21 +00:00
Dax Raad 0c022ef39d ci: stuff 2025-10-14 14:35:04 -04:00
Aiden Cline 717b544633 fix: false positive package manager detection in upgrade (#3181) 2025-10-14 13:18:40 -05:00
Frank c1a420717a ci: fix 2025-10-14 13:58:54 -04:00
Frank 42c2ffd842 wip: zen 2025-10-14 13:52:30 -04:00
GitHub Action 5192c51843 chore: format code 2025-10-14 17:08:18 +00:00
Adam 96d7ccea48 wip: css/ui work 2025-10-14 12:07:45 -05:00
Adam 49e859cfd6 wip: css/ui work 2025-10-14 12:06:18 -05:00
Adam 6c57a69af4 wip: desktop work 2025-10-14 12:06:17 -05:00
Netanel Draiman 4d019430e2 feat(cli): add session option to attach command (#3167) 2025-10-14 11:04:32 -05:00
Adam 37e6c8342f wip: css and ui packages 2025-10-14 07:16:24 -05:00
Adam c04e892991 wip: desktop work 2025-10-14 07:15:08 -05:00
Adam bb82d43094 wip: desktop work 2025-10-14 07:15:08 -05:00
GitHub Action 2893b6e3a5 ignore: update download stats 2025-10-14 2025-10-14 12:04:50 +00:00
Dax 54c3361be7 feat: use realtime events for live tool call updates in Slack (#3163) 2025-10-14 03:13:48 -04:00
Dax Raad c50cf21f18 fix: update tsconfig for Slack package 2025-10-14 02:55:21 -04:00
Dax Raad cb73e2d9e1 fix: export trimDiff function from edit tool 2025-10-14 02:55:02 -04:00
Dax Raad 48057c2c21 fix: resolve TypeScript errors in SDK and Slack package 2025-10-14 02:54:37 -04:00
Dax Raad 1923ddab6e feat: add Slack integration package with Bolt framework 2025-10-14 02:53:55 -04:00
Dax Raad b8249cde4b core: improve dependency management and error handling for more reliable builds 2025-10-14 01:33:25 -04:00
Dax Raad 19b3f3d7ce core: standardize dependency versions for better reliability
Ensures consistent versions across packages by using workspace catalog for
tailwindcss and tsconfig dependencies, reducing potential conflicts and
installation issues.
2025-10-14 01:27:17 -04:00
Dax Raad e5e05d390d core: reduce dependency conflicts by standardizing package versions through catalog
This eliminates duplicate package versions that were causing build issues and
inconsistent behavior across the monorepo. Dependencies now resolve to single
versions through the workspace catalog, making installs faster and more reliable.
2025-10-14 01:23:54 -04:00
opencode 38ad6707cf release: v0.15.2 2025-10-14 04:56:00 +00:00
Alberto Fanton 7ef246f98f fix: disable GPG signing in snapshot tests (#3102) 2025-10-13 23:40:41 -05:00
Aiden Cline b91582d68a fix: config dir overrides (#3160) 2025-10-13 23:25:53 -05:00
Aiden Cline 682d30bd12 fix: custom model (#3156) 2025-10-13 19:58:19 -05:00
pancake 4d68ee5d2c fix: clang formatter name (#3042)
Co-authored-by: pancake <pancake@nopcode.org>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-10-13 19:55:18 -05:00
Haris Gušić dbe9fd00b7 fix: make shell more robust (#3051) 2025-10-13 17:37:35 -05:00
maple cd13a8524e docs: typo in custom-tools.mdx (#3152) 2025-10-13 17:19:20 -05:00
Aiden Cline 59765e0157 fix: typecheck (#3149) 2025-10-13 14:51:12 -05:00
AB d0519be0d0 fix: add useCompletionUrls option to fix certain azure setups (#2528)
Co-authored-by: andreas.blass <andreas.blass@outlook.com>
Co-authored-by: GitHub Action <action@github.com>
2025-10-13 14:16:21 -05:00
Tommy D. Rossi 066e4f064d tweak: include stack trace in server error responses (#3134) 2025-10-13 14:10:35 -05:00
opencode f81c469f17 release: v0.15.1 2025-10-13 18:14:52 +00:00
Dax Raad a398013ecb fix: disable workspace symbol lookup to prevent LSP performance issues 2025-10-13 14:05:54 -04:00
Aiden Cline 53d9717d90 fix: pass options to compact (#3136) 2025-10-13 10:42:39 -05:00
Aiden Cline 5885b691b9 docs: update recommended models list (#3121) 2025-10-12 21:35:31 -05:00
Aiden Cline fd70b9b057 fix: adjust list tool prompt to handle cwd better (#3115) 2025-10-12 16:48:03 -05:00
Ravshan Samandarov de13ccb757 docs: Update README.md (#3100) 2025-10-12 16:23:25 -04:00
Jay 7e1abb7bbf docs: Fix formatting of num_ctx in providers.mdx 2025-10-12 16:01:32 -04:00
Aiden Cline 83afcb9c42 docs: ollama num_ctx (#3111) 2025-10-12 10:40:51 -05:00
GitHub Action afb406c5ff ignore: update download stats 2025-10-12 2025-10-12 12:04:09 +00:00
OpeOginni 36cf9b9922 fix: add timeout to fetch models.dev refresh request (#3059) 2025-10-12 00:20:22 -05:00
225 changed files with 13550 additions and 3207 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Bug report
description: Report an issue that should be fixed
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: Description
description: Describe the bug you encountered
placeholder: What happened?
validations:
required: true
- type: input
id: opencode-version
attributes:
label: OpenCode version
description: What version of OpenCode are you using?
validations:
required: false
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: How can we reproduce this issue?
placeholder: |
1.
2.
3.
validations:
required: false
- type: textarea
id: screenshot-or-link
attributes:
label: Screenshot and/or share link
description: Run `/share` to get a share link, or attach a screenshot
placeholder: Paste link or drag and drop screenshot here
validations:
required: false
- type: input
id: os
attributes:
label: Operating System
description: what OS are you using?
placeholder: e.g., macOS 26.0.1, Ubuntu 22.04, Windows 11
validations:
required: false
- type: input
id: terminal
attributes:
label: Terminal
description: what terminal are you using?
placeholder: e.g., iTerm2, Ghostty, Alacritty, Windows Terminal
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: 💬 Discord Community
url: https://discord.gg/opencode
about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
@@ -0,0 +1,20 @@
name: 🚀 Feature Request
description: Suggest an idea, feature, or enhancement
labels: [discussion]
title: "[FEATURE]:"
body:
- type: checkboxes
id: verified
attributes:
label: Feature hasn't been suggested before.
options:
- label: I have verified this feature I'm about to request hasn't been suggested before.
required: true
- type: textarea
attributes:
label: Describe the enhancement you want to request
description: What do you want to change or add? What are the benefits of implementing this? Try to be detailed so we can understand your request better :)
validations:
required: true
+11
View File
@@ -0,0 +1,11 @@
name: Question
description: Ask a question
labels: ["question"]
body:
- type: textarea
id: question
attributes:
label: Question
description: What's your question?
validations:
required: true
+4 -2
View File
@@ -8,7 +8,9 @@ jobs:
opencode:
if: |
contains(github.event.comment.body, ' /oc') ||
contains(github.event.comment.body, ' /opencode')
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest
permissions:
id-token: write
@@ -24,4 +26,4 @@ jobs:
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: opencode/sonic
model: opencode/kimi-k2
+1
View File
@@ -58,6 +58,7 @@ jobs:
./script/publish.ts
env:
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_CHANNEL: latest
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
+1 -3
View File
@@ -28,9 +28,7 @@ jobs:
- name: Publish
run: |
./packages/opencode/script/publish.ts
./script/publish.ts
env:
OPENCODE_SNAPSHOT: true
OPENCODE_TAG: ${{ github.ref_name }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
+1
View File
@@ -24,6 +24,7 @@ jobs:
run: |
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
bun turbo typecheck
bun turbo test
env:
CI: true
+1 -1
View File
@@ -13,7 +13,7 @@ avoid repeating the title of the page, should be 5-10 words long
Chunks of text should not be more than 2 sentences long
Each section is spearated by a divider of 3 dashes
Each section is separated by a divider of 3 dashes
The section titles are short with only the first letter of the word capitalized
+8
View File
@@ -1,3 +1,7 @@
---
description: Git commit and push
---
commit and push
make sure it includes a prefix like
@@ -8,5 +12,9 @@ ci:
ignore:
wip:
For anything in the packages/web use the docs: prefix.
For anything in the packages/app use the ignore: prefix.
prefer to explain WHY something was done from an end user perspective instead of
WHAT was done.
+71
View File
@@ -0,0 +1,71 @@
# Contributing to OpenCode
We want to make it easy for you to contribute to OpenCode. Here are the most common type of changes that get merged:
- Bug fixes
- Additional LSPs / Formatters
- Improvements to LLM performance
- Support for new providers
- Fixes for environment-specific quirks
- Missing standard behavior
- Documentation improvements
However, any UI or core product feature must go through a design review with the core team before implementation.
> [!IMPORTANT]
> We do not accept PRs for core features.
Take a look at recent git history to understand what usually lands.
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with either of the following labels:
- [`help wanted`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`bug`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
> [!NOTE]
> PRs that ignore these guardrails will likely be closed.
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
## Developing OpenCode
- Requirements: Bun 1.3+, Go 1.24.x.
- Install dependencies and start the dev server from the repo root:
```bash
bun install
bun dev
```
- Core pieces:
- `packages/opencode`: OpenCode core business logic & server.
- `packages/tui`: The TUI code, written in Go (will be removed soon in favor of [opentui](https://github.com/sst/opentui))
- `packages/plugin`: Source for `@opencode-ai/plugin`
> [!NOTE]
> After touching `packages/opencode/src/server/server.ts`, the OpenCode team must regenerate the Stainless SDK before any client updates merge.
## Pull Request Expectations
- Try to keep pull requests small and focused.
- Link relevant issue(s) in the description
- Explain the issue and why your change fixes it
- Avoid having verbose LLM generated PR descriptions
- Before adding new functions or functionality, ensure that such behavior doesn't already exist elsewhere in the codebase.
### Style Preferences
These are not strictly enforced, they are just general guidelines:
- **Functions:** Keep logic within a single function unless breaking it out adds clear reuse or composition benefits.
- **Destructuring:** Do not do unnecessary destructuring of variables.
- **Control flow:** Avoid `else` statements.
- **Error handling:** Prefer `.catch(...)` instead of `try`/`catch` when possible.
- **Types:** Reach for precise types and avoid `any`.
- **Variables:** Stick to immutable patterns and avoid `let`.
- **Naming:** Choose concise single-word identifiers when they remain descriptive.
- **Runtime APIs:** Use Bun helpers such as `Bun.file()` when they fit the use case.
## Feature Requests
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
+4 -35
View File
@@ -26,6 +26,8 @@ curl -fsSL https://opencode.ai/install | bash
# Package managers
npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
winget install opencode # Windows
brew install sst/tap/opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
```
@@ -54,41 +56,7 @@ For more info on how to configure OpenCode [**head over to our docs**](https://o
### Contributing
OpenCode is an opinionated tool so any fundamental feature needs to go through a
design process with the core team.
> [!IMPORTANT]
> We do not accept PRs for core features.
However we still merge a ton of PRs - you can contribute:
- Bug fixes
- Improvements to LLM performance
- Support for new providers
- Fixes for env specific quirks
- Missing standard behavior
- Documentation
Take a look at the git history to see what kind of PRs we end up merging.
> [!NOTE]
> If you do not follow the above guidelines we might close your PR.
To run OpenCode locally you need.
- Bun
- Golang 1.24.x
And run.
```bash
$ bun install
$ bun dev
```
#### Development Notes
**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the OpenCode team to generate a new stainless sdk for the clients.
If you're interested in contributing to OpenCode, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request.
### FAQ
@@ -98,6 +66,7 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
- 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.
- Out of the box LSP support
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
+114 -106
View File
@@ -1,108 +1,116 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | ----------------- | ----------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ----------------- | ----------------- | ------------------ |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
+709 -248
View File
File diff suppressed because it is too large Load Diff
+4 -18
View File
@@ -6,15 +6,11 @@ branding:
inputs:
model:
description: "The model to use with opencode. Takes the format of `provider/model`."
description: "Model to use"
required: true
share:
description: "Whether to share the opencode session. Defaults to true for public repositories."
required: false
token:
description: "Optional GitHub access token for performing operations such as creating comments, committing changes, and opening pull requests. Defaults to the installation access token from the opencode GitHub App."
description: "Share the opencode session (defaults to true for public repos)"
required: false
runs:
@@ -24,20 +20,10 @@ runs:
shell: bash
run: curl -fsSL https://opencode.ai/install | bash
- name: Install bun
shell: bash
run: npm install -g bun
- name: Install dependencies
shell: bash
run: |
cd ${GITHUB_ACTION_PATH}
bun install
- name: Run opencode
shell: bash
run: bun ${GITHUB_ACTION_PATH}/index.ts
id: run_opencode
run: opencode github run
env:
MODEL: ${{ inputs.model }}
SHARE: ${{ inputs.share }}
TOKEN: ${{ inputs.token }}
+1 -1
View File
@@ -14,6 +14,6 @@
"@actions/github": "6.0.1",
"@octokit/graphql": "9.0.1",
"@octokit/rest": "22.0.0",
"@opencode-ai/sdk": "0.5.4"
"@opencode-ai/sdk": "workspace:*"
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { domain } from "./stage"
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
export const EMAILOCTOPUS_API_KEY = new sst.Secret("EMAILOCTOPUS_API_KEY")
const ADMIN_SECRET = new sst.Secret("ADMIN_SECRET")
const bucket = new sst.cloudflare.Bucket("Bucket")
export const api = new sst.cloudflare.Worker("Api", {
@@ -12,7 +13,7 @@ export const api = new sst.cloudflare.Worker("Api", {
WEB_DOMAIN: domain,
},
url: true,
link: [bucket, GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY],
link: [bucket, GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, ADMIN_SECRET],
transform: {
worker: (args) => {
args.logpush = true
+1
View File
@@ -96,6 +96,7 @@ download_and_install() {
curl -# -L -o "$filename" "$url"
unzip -q "$filename"
mv opencode "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode"
cd .. && rm -rf opencodetmp
}
+20 -5
View File
@@ -13,7 +13,8 @@
"packages": [
"packages/*",
"packages/console/*",
"packages/sdk/js"
"packages/sdk/js",
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.0",
@@ -21,25 +22,38 @@
"@kobalte/core": "0.13.11",
"@types/node": "22.13.9",
"@tsconfig/node22": "22.0.2",
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.8",
"hono": "4.7.10",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251014.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"solid-js": "1.9.9"
"solid-js": "1.9.9",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
"vite": "7.1.4",
"vite-plugin-solid": "2.11.8"
}
},
"devDependencies": {
"@tsconfig/bun": "1.0.9",
"@tsconfig/bun": "catalog:",
"husky": "9.1.7",
"prettier": "3.6.2",
"sst": "3.17.19",
"turbo": "2.5.6"
},
"dependencies": {
"@opencode-ai/sdk": "workspace:*"
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/script": "workspace:*"
},
"repository": {
"type": "git",
@@ -62,6 +76,7 @@
"@solidjs/start@1.1.7": "patches/@solidjs%2Fstart@1.1.7.patch"
},
"overrides": {
"@types/bun": "catalog:"
"@types/bun": "catalog:",
"@types/node": "catalog:"
}
}
+4 -3
View File
@@ -2,12 +2,12 @@
"name": "@opencode-ai/console-app",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"typecheck": "tsgo --noEmit",
"dev": "vinxi dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "0.15.0"
"version": "0.15.9"
},
"dependencies": {
"@ibm/plex": "6.4.1",
@@ -23,7 +23,8 @@
"zod": "catalog:"
},
"devDependencies": {
"typescript": "catalog:"
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
},
"engines": {
"node": ">=22"
@@ -100,6 +100,17 @@ export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
export function IconStripe(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24">
<path
fill="currentColor"
d="M15.827 12.506c0 .672-.31 1.175-.771 1.175-.293 0-.468-.106-.589-.237l-.007-1.855c.13-.143.31-.247.596-.247.456-.001.771.51.771 1.164zm3.36-1.253c-.312 0-.659.236-.659.798h1.291c0-.562-.325-.798-.632-.798zm4.813-5.253v12c0 1.104-.896 2-2 2h-20c-1.104 0-2-.896-2-2v-12c0-1.104.896-2 2-2h20c1.104 0 2 .896 2 2zm-17.829 7.372c0-1.489-1.909-1.222-1.909-1.784 0-.195.162-.271.424-.271.38 0 .862.116 1.242.321v-1.176c-.414-.165-.827-.228-1.241-.228-1.012.001-1.687.53-1.687 1.414 0 1.382 1.898 1.158 1.898 1.754 0 .231-.201.305-.479.305-.414 0-.947-.171-1.366-.399v1.192c.464.2.935.283 1.365.283 1.038.001 1.753-.512 1.753-1.411zm2.422-3.054h-.949l-.001-1.084-1.219.259-.005 4.006c0 .739.556 1.285 1.297 1.285.408 0 .71-.074.876-.165v-1.016c-.16.064-.948.293-.948-.443v-1.776h.948v-1.066zm2.596 0c-.166-.06-.75-.169-1.042.369l-.078-.369h-1.079v4.377h1.248v-2.967c.295-.388.793-.313.952-.262v-1.148zm1.554 0h-1.253v4.377h1.253v-4.377zm0-1.664l-1.253.266v1.017l1.253-.266v-1.017zm4.314 3.824c0-1.454-.826-2.244-1.703-2.243-.489 0-.805.23-.978.392l-.065-.309h-1.099v5.828l1.249-.265.003-1.413c.179.131.446.316.883.316.893 0 1.71-.719 1.71-2.306zm3.943.045c0-1.279-.619-2.288-1.805-2.288-1.188 0-1.911 1.01-1.911 2.281 0 1.506.852 2.267 2.068 2.267.597 0 1.045-.136 1.384-.324v-1.006c-.34.172-.731.276-1.227.276-.487 0-.915-.172-.971-.758h2.444l.018-.448z"
/>
</svg>
)
}
export function IconChevron(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -118,3 +129,86 @@ export function IconWorkspaceLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg>
)
}
export function IconOpenAI(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" fill="none">
<path
d="M8.43799 8.06943V6.09387C8.43799 5.92749 8.50347 5.80267 8.65601 5.71959L12.8206 3.43211C13.3875 3.1202 14.0635 2.9747 14.7611 2.9747C17.3775 2.9747 19.0347 4.9087 19.0347 6.96734C19.0347 7.11288 19.0347 7.27926 19.0128 7.44564L14.6956 5.03335C14.434 4.88785 14.1723 4.88785 13.9107 5.03335L8.43799 8.06943ZM18.1624 15.7637V11.0431C18.1624 10.7519 18.0315 10.544 17.7699 10.3984L12.2972 7.36234L14.0851 6.3849C14.2377 6.30182 14.3686 6.30182 14.5212 6.3849L18.6858 8.67238C19.8851 9.3379 20.6917 10.7519 20.6917 12.1243C20.6917 13.7047 19.7106 15.1604 18.1624 15.7636V15.7637ZM7.15158 11.6047L5.36369 10.6066C5.21114 10.5235 5.14566 10.3986 5.14566 10.2323V5.65735C5.14566 3.43233 6.93355 1.7478 9.35381 1.7478C10.2697 1.7478 11.1199 2.039 11.8396 2.55886L7.54424 4.92959C7.28268 5.07508 7.15181 5.28303 7.15181 5.57427V11.6049L7.15158 11.6047ZM11 13.7258L8.43799 12.3533V9.44209L11 8.06965L13.5618 9.44209V12.3533L11 13.7258ZM12.6461 20.0476C11.7303 20.0476 10.8801 19.7564 10.1604 19.2366L14.4557 16.8658C14.7173 16.7203 14.8482 16.5124 14.8482 16.2211V10.1905L16.658 11.1886C16.8105 11.2717 16.876 11.3965 16.876 11.563V16.1379C16.876 18.3629 15.0662 20.0474 12.6461 20.0474V20.0476ZM7.47863 15.4103L3.314 13.1229C2.11471 12.4573 1.30808 11.0433 1.30808 9.67088C1.30808 8.06965 2.31106 6.6348 3.85903 6.03168V10.773C3.85903 11.0642 3.98995 11.2721 4.25151 11.4177L9.70253 14.4328L7.91464 15.4103C7.76209 15.4934 7.63117 15.4934 7.47863 15.4103ZM7.23892 18.8207C4.77508 18.8207 2.96533 17.0531 2.96533 14.8696C2.96533 14.7032 2.98719 14.5368 3.00886 14.3704L7.30418 16.7412C7.56574 16.8867 7.82752 16.8867 8.08909 16.7412L13.5618 13.726V15.7015C13.5618 15.8679 13.4964 15.9927 13.3438 16.0758L9.17918 18.3633C8.61225 18.6752 7.93631 18.8207 7.23869 18.8207H7.23892ZM12.6461 21.2952C15.2844 21.2952 17.4865 19.5069 17.9882 17.1362C20.4301 16.5331 22 14.3495 22 12.1245C22 10.6688 21.346 9.25482 20.1685 8.23581C20.2775 7.79908 20.343 7.36234 20.343 6.92582C20.343 3.95215 17.8137 1.72691 14.892 1.72691C14.3034 1.72691 13.7365 1.80999 13.1695 1.99726C12.1882 1.08223 10.8364 0.5 9.35381 0.5C6.71557 0.5 4.51352 2.28829 4.01185 4.65902C1.56987 5.26214 0 7.44564 0 9.67067C0 11.1264 0.654039 12.5404 1.83147 13.5594C1.72246 13.9961 1.65702 14.4328 1.65702 14.8694C1.65702 17.8431 4.1863 20.0683 7.108 20.0683C7.69661 20.0683 8.26354 19.9852 8.83046 19.7979C9.81155 20.713 11.1634 21.2952 12.6461 21.2952Z"
fill="currentColor"
/>
</svg>
)
}
export function IconAnthropic(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M13.7891 3.93188L20.2223 20.068H23.7502L17.317 3.93188H13.7891Z" />
<path
fill="currentColor"
d="M6.32538 13.6827L8.52662 8.01201L10.7279 13.6827H6.32538ZM6.68225 3.93188L0.25 20.068H3.84652L5.16202 16.6794H11.8914L13.2067 20.068H16.8033L10.371 3.93188H6.68225Z"
/>
</svg>
)
}
export function IconXai(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path
d="M9.16861 16.0529L17.2018 9.85156C17.5957 9.54755 18.1586 9.66612 18.3463 10.1384C19.3339 12.6288 18.8926 15.6217 16.9276 17.6766C14.9626 19.7314 12.2285 20.1821 9.72948 19.1557L6.9995 20.4775C10.9151 23.2763 15.6699 22.5841 18.6411 19.4749C20.9979 17.0103 21.7278 13.6508 21.0453 10.6214L21.0515 10.6278C20.0617 6.17736 21.2948 4.39847 23.8207 0.760904C23.8804 0.674655 23.9402 0.588405 24 0.5L20.6762 3.97585V3.96506L9.16658 16.0551"
fill="currentColor"
/>
<path
d="M7.37742 16.7017C4.67579 14.0395 5.14158 9.91963 7.44676 7.54383C9.15135 5.78544 11.9442 5.06779 14.3821 6.12281L17.0005 4.87559C16.5288 4.52392 15.9242 4.14566 15.2305 3.87986C12.0948 2.54882 8.34069 3.21127 5.79171 5.8386C3.33985 8.36779 2.56881 12.2567 3.89286 15.5751C4.88192 18.0552 3.26056 19.8094 1.62731 21.5801C1.04853 22.2078 0.467774 22.8355 0 23.5L7.3754 16.7037"
fill="currentColor"
/>
</svg>
)
}
export function IconAlibaba(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" fill="none">
<path
fill-rule="evenodd"
clip-rule="evenodd"
fill="currentColor"
d="M11.6043 0.340162C11.9973 1.03016 12.3883 1.72215 12.7783 2.41514C12.7941 2.44286 12.8169 2.46589 12.8445 2.48187C12.8721 2.49786 12.9034 2.50624 12.9353 2.50614H18.4873C18.6612 2.50614 18.8092 2.61614 18.9332 2.83314L20.3872 5.40311C20.5772 5.74011 20.6272 5.88111 20.4112 6.24011C20.1512 6.6701 19.8982 7.1041 19.6512 7.54009L19.2842 8.19809C19.1782 8.39409 19.0612 8.47809 19.2442 8.71008L21.8962 13.347C22.0682 13.648 22.0072 13.841 21.8532 14.117C21.4162 14.902 20.9712 15.681 20.5182 16.457C20.3592 16.729 20.1662 16.832 19.8382 16.827C19.0612 16.811 18.2863 16.817 17.5113 16.843C17.4946 16.8439 17.4785 16.8489 17.4644 16.8576C17.4502 16.8664 17.4385 16.8785 17.4303 16.893C16.5361 18.4773 15.6344 20.0573 14.7253 21.633C14.5563 21.926 14.3453 21.996 14.0003 21.997C13.0033 22 11.9983 22.001 10.9833 21.999C10.8889 21.9987 10.7961 21.9735 10.7145 21.9259C10.6328 21.8783 10.5652 21.8101 10.5184 21.728L9.18337 19.405C9.1756 19.3898 9.16368 19.3771 9.14898 19.3684C9.13429 19.3598 9.11743 19.3554 9.10037 19.356H3.98244C3.69744 19.386 3.42944 19.355 3.17745 19.264L1.57447 16.494C1.52706 16.412 1.50193 16.319 1.50158 16.2243C1.50123 16.1296 1.52567 16.0364 1.57247 15.954L2.77945 13.834C2.79665 13.8041 2.80569 13.7701 2.80569 13.7355C2.80569 13.701 2.79665 13.667 2.77945 13.637C2.15073 12.5485 1.52573 11.4579 0.904476 10.3651L0.114486 8.97008C-0.0455115 8.66008 -0.0585113 8.47409 0.209485 8.00509C0.674479 7.1921 1.13647 6.38011 1.59647 5.56911C1.72847 5.33512 1.90046 5.23512 2.18046 5.23412C3.04344 5.23048 3.90644 5.23015 4.76943 5.23312C4.79123 5.23295 4.81259 5.22704 4.83138 5.21597C4.85016 5.20491 4.8657 5.1891 4.87643 5.17012L7.68239 0.275163C7.72491 0.200697 7.78631 0.138751 7.86039 0.0955646C7.93448 0.0523783 8.01863 0.0294762 8.10439 0.0291651C8.62838 0.0281651 9.15737 0.029165 9.68736 0.0231651L10.7044 0.000165317C11.0453 -0.00283466 11.4283 0.032165 11.6043 0.340162ZM8.17238 0.743158C8.16185 0.743152 8.15149 0.745921 8.14236 0.751187C8.13323 0.756454 8.12565 0.764031 8.12038 0.773158L5.25442 5.78811C5.24066 5.81174 5.22097 5.83137 5.19729 5.84505C5.17361 5.85873 5.14677 5.86599 5.11942 5.86611H2.25346C2.19746 5.86611 2.18346 5.89111 2.21246 5.94011L8.02239 16.096C8.04739 16.138 8.03539 16.158 7.98839 16.159L5.19342 16.174C5.15256 16.1727 5.11214 16.1828 5.07678 16.2033C5.04141 16.2238 5.01253 16.2539 4.99342 16.29L3.67344 18.6C3.62944 18.678 3.65244 18.718 3.74144 18.718L9.45737 18.726C9.50337 18.726 9.53737 18.746 9.56137 18.787L10.9643 21.241C11.0103 21.322 11.0563 21.323 11.1033 21.241L16.1093 12.481L16.8923 11.0991C16.897 11.0905 16.904 11.0834 16.9125 11.0785C16.9209 11.0735 16.9305 11.0709 16.9403 11.0709C16.9501 11.0709 16.9597 11.0735 16.9681 11.0785C16.9765 11.0834 16.9835 11.0905 16.9883 11.0991L18.4123 13.629C18.4229 13.648 18.4385 13.6637 18.4573 13.6746C18.4761 13.6855 18.4975 13.6912 18.5193 13.691L21.2822 13.671C21.2893 13.6711 21.2963 13.6693 21.3024 13.6658C21.3086 13.6623 21.3137 13.6572 21.3172 13.651C21.3206 13.6449 21.3224 13.638 21.3224 13.631C21.3224 13.624 21.3206 13.6172 21.3172 13.611L18.4173 8.52508C18.4068 8.50809 18.4013 8.48853 18.4013 8.46859C18.4013 8.44864 18.4068 8.42908 18.4173 8.41209L18.7102 7.90509L19.8302 5.92811C19.8542 5.88711 19.8422 5.86611 19.7952 5.86611H8.20038C8.14138 5.86611 8.12738 5.84011 8.15738 5.78911L9.59137 3.28413C9.60211 3.26706 9.60781 3.24731 9.60781 3.22714C9.60781 3.20697 9.60211 3.18721 9.59137 3.17014L8.22538 0.774158C8.22016 0.764697 8.21248 0.756822 8.20315 0.751365C8.19382 0.745909 8.18319 0.743073 8.17238 0.743158ZM14.4623 8.76308C14.5083 8.76308 14.5203 8.78308 14.4963 8.82308L13.6643 10.2881L11.0513 14.873C11.0464 14.8819 11.0392 14.8894 11.0304 14.8945C11.0216 14.8996 11.0115 14.9022 11.0013 14.902C10.9912 14.902 10.9813 14.8993 10.9725 14.8942C10.9637 14.8891 10.9564 14.8818 10.9513 14.873L7.49839 8.84108C7.47839 8.80708 7.48839 8.78908 7.52639 8.78708L7.74239 8.77508L14.4643 8.76308H14.4623Z"
/>
</svg>
)
}
export function IconMoonshotAI(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M20.052 6.0364C18.7527 4.28846 16.8758 2.94985 14.6066 2.34296C12.3374 1.73606 10.0411 1.95816 8.04092 2.82331L20.052 6.0364ZM3.75774 6.34071C4.64576 5.04902 5.81872 3.99836 7.16325 3.25459L16.0115 5.62191C15.4308 5.95635 14.8088 6.53268 14.3273 7.16626L21.6025 9.11263C21.809 9.79398 21.9435 10.5003 22 11.2213L3.75774 6.34071ZM21.6866 14.5876C21.584 14.9707 21.4603 15.3425 21.3172 15.7019L2.10543 10.5623C2.16147 10.1792 2.24079 9.7957 2.34339 9.41263C2.58479 8.51262 2.94172 7.67459 3.39435 6.91016L12.7957 9.42554C12.4194 9.96271 12.0766 10.5464 11.7749 11.1709L21.8517 13.8671C21.8056 14.1077 21.7504 14.3478 21.6862 14.5884L21.6866 14.5876ZM2.58134 15.3529C2.11535 14.05 1.91662 12.6408 2.03215 11.2088L10.8985 13.5808C10.8347 13.7823 10.7748 13.9863 10.7192 14.1933C10.6062 14.6147 10.514 15.0352 10.4424 15.4527L20.0166 18.0142C19.5709 18.6051 19.0631 19.1406 18.5057 19.6132L2.58134 15.3529ZM9.42338 21.6568C6.40111 20.8481 4.07415 18.7424 2.88266 16.1001L10.0976 18.0305C10.0859 18.7024 10.1278 19.3571 10.2196 19.9851L15.4619 21.3874C13.5906 22.0743 11.496 22.2112 9.42338 21.6568Z"
fill="currentColor"
/>
</svg>
)
}
export function IconZai(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path>
</svg>
)
}
export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">
<path
d="M24 15.5L18.1816 14.2871L18.1328 14.3115C16.9036 11.7879 15.6135 9.29301 14.2607 6.82812C14.0172 6.38435 13.771 5.94188 13.5234 5.5C13.6911 5.97998 13.8606 6.45942 14.0322 6.9375C14.9902 9.60529 16.012 12.2429 17.0947 14.8516L12 17.5L6.9043 14.8516C7.98712 12.2428 9.00977 9.6054 9.96777 6.9375C10.1394 6.45942 10.3089 5.97998 10.4766 5.5C10.229 5.94188 9.98281 6.38435 9.73926 6.82812C8.38629 9.29339 7.09557 11.7884 5.86621 14.3125L5.81738 14.2871L0 15.5L12 0.5L24 15.5Z"
fill="currentColor"
/>
</svg>
)
}
+8 -2
View File
@@ -1,5 +1,5 @@
import { getRequestEvent } from "solid-js/web"
import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { and, Database, eq, inArray, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { redirect } from "@solidjs/router"
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -56,7 +56,13 @@ export const getActor = async (workspace?: string): Promise<Actor.Info> => {
tx
.select()
.from(UserTable)
.where(and(eq(UserTable.workspaceID, workspace), inArray(UserTable.accountID, accounts)))
.where(
and(
eq(UserTable.workspaceID, workspace),
isNull(UserTable.timeDeleted),
inArray(UserTable.accountID, accounts),
),
)
.limit(1)
.execute()
.then((x) => x[0]),
+3 -22
View File
@@ -1,30 +1,11 @@
import { Actor } from "@opencode-ai/console-core/actor.js"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { withActor } from "~/context/auth.withActor"
import { getLastSeenWorkspaceID } from "../workspace/common"
export async function GET(input: APIEvent) {
try {
const workspaces = await withActor(async () => {
const actor = Actor.assert("account")
return Database.transaction(async (tx) =>
tx
.select({ id: WorkspaceTable.id })
.from(UserTable)
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.where(
and(
eq(UserTable.accountID, actor.properties.accountID),
isNull(UserTable.timeDeleted),
isNull(WorkspaceTable.timeDeleted),
),
),
)
})
return redirect(`/workspace/${workspaces[0].id}`)
const workspaceID = await getLastSeenWorkspaceID()
return redirect(`/workspace/${workspaceID}`)
} catch {
return redirect("/auth/authorize")
}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function Home() {
<main data-page="opencode">
<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
<Title>OpenCode | The AI coding agent built for the terminal</Title>
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<Meta property="og:image" content="/social-share.png" />
<Meta name="twitter:image" content="/social-share.png" />
<div data-component="container">
@@ -32,7 +32,8 @@ export async function POST(input: APIEvent) {
.update(BillingTable)
.set({
paymentMethodID,
paymentMethodLast4: paymentMethod.card!.last4,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
})
.where(eq(BillingTable.customerID, customerID))
})
@@ -77,7 +78,8 @@ export async function POST(input: APIEvent) {
balance: sql`${BillingTable.balance} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
customerID,
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card!.last4,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
reload: true,
reloadError: null,
timeReloadError: null,
@@ -23,7 +23,13 @@ const getWorkspaces = query(async () => {
})
.from(UserTable)
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.where(and(eq(UserTable.accountID, Actor.account()), isNull(WorkspaceTable.timeDeleted))),
.where(
and(
eq(UserTable.accountID, Actor.account()),
isNull(WorkspaceTable.timeDeleted),
isNull(UserTable.timeDeleted),
),
),
)
})
}, "workspaces")
@@ -1,4 +1,3 @@
import { Show } from "solid-js"
import { query, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import "./workspace.css"
import { IconWorkspaceLogo } from "../component/icon"
@@ -7,13 +6,13 @@ import { UserMenu } from "./user-menu"
import { withActor } from "~/context/auth.withActor"
import { User } from "@opencode-ai/console-core/user.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { querySessionInfo } from "./workspace/common"
import { Link } from "@solidjs/meta"
const getUserEmail = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const actor = Actor.assert("user")
const email = await User.getAccountEmail(actor.properties.userID)
const email = await User.getAuthEmail(actor.properties.userID)
return email
}, workspaceID)
}, "userEmail")
@@ -21,17 +20,15 @@ const getUserEmail = query(async (workspaceID: string) => {
export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams()
const userEmail = createAsync(() => getUserEmail(params.id))
const sessionInfo = createAsync(() => querySessionInfo(params.id))
return (
<main data-page="workspace">
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
<header data-component="workspace-header">
<div data-slot="header-brand">
<A href="/" data-component="site-title">
<IconWorkspaceLogo />
</A>
<Show when={sessionInfo()?.isBeta}>
<WorkspacePicker />
</Show>
<WorkspacePicker />
</div>
<div data-slot="header-actions">
<UserMenu email={userEmail()} />
@@ -16,38 +16,110 @@
justify-content: flex-end;
}
[data-component="workspace-nav-items"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
/* Desktop Navigation */
[data-component="nav-desktop"] {
display: block;
[data-nav-button] {
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-sm);
color: var(--color-text-muted);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: 500;
transition: all 0.15s ease;
@media (max-width: 48rem) {
display: none;
}
&:hover {
color: var(--color-text);
[data-component="workspace-nav-items"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
[data-nav-button] {
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-sm);
color: var(--color-text-muted);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: 500;
transition: all 0.15s ease;
&:hover {
color: var(--color-text);
}
&.active {
color: var(--color-text);
font-weight: 700;
position: relative;
&::before {
content: "";
position: absolute;
left: calc(-1 * var(--space-0-5));
top: 0;
bottom: 0;
width: 2px;
background-color: var(--color-text);
border-radius: 0 2px 2px 0;
}
}
}
}
}
&.active {
color: var(--color-text);
font-weight: 700;
/* Mobile Navigation */
[data-component="nav-mobile"] {
display: none;
@media (max-width: 48rem) {
display: flex;
align-items: stretch;
justify-content: flex-start;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-component="workspace-nav-items"] {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--space-1);
min-width: max-content;
height: 100%;
[data-nav-button] {
padding: var(--space-2) var(--space-3);
padding-bottom: calc(var(--space-2) + 4px);
border-radius: var(--border-radius-sm);
color: var(--color-text-muted);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: 500;
transition: all 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
position: relative;
&::before {
content: '';
position: absolute;
left: calc(-1 * var(--space-0-5));
top: 0;
bottom: 0;
width: 2px;
background-color: var(--color-text);
border-radius: 0 2px 2px 0;
&:hover {
color: var(--color-text);
}
&.active {
color: var(--color-text);
font-weight: 700;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: var(--color-text);
border-radius: 2px 2px 0 0;
}
}
}
}
@@ -221,5 +293,16 @@
border-right: none;
border-bottom: 1px solid var(--color-border);
padding: var(--space-4);
justify-content: flex-start;
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
min-height: fit-content;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
}
}
@@ -6,29 +6,54 @@ import "./[id].css"
export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id))
return (
<main data-page="workspace">
<div data-component="workspace-container">
<nav data-component="workspace-nav">
<div data-component="workspace-nav-items">
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
Zen
</A>
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
API Keys
</A>
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
Members
</A>
<Show when={userInfo()?.isAdmin}>
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
Billing
<nav data-component="nav-desktop">
<div data-component="workspace-nav-items">
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
Zen
</A>
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
Settings
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
API Keys
</A>
</Show>
</div>
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
Members
</A>
<Show when={userInfo()?.isAdmin}>
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
Billing
</A>
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
Settings
</A>
</Show>
</div>
</nav>
<nav data-component="nav-mobile">
<div data-component="workspace-nav-items">
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
Zen
</A>
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
API Keys
</A>
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
Members
</A>
<Show when={userInfo()?.isAdmin}>
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
Billing
</A>
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
Settings
</A>
</Show>
</div>
</nav>
</nav>
<div data-component="workspace-content">{props.children}</div>
</div>
@@ -70,6 +70,12 @@
font-weight: 500;
color: var(--color-text);
}
[data-slot="type"] {
font-size: var(--font-size-sm);
font-weight: 400;
color: var(--color-text-muted);
}
}
}
@@ -1,8 +1,8 @@
import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import { createMemo, Match, Show, Switch } from "solid-js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { IconCreditCard } from "~/component/icon"
import { IconCreditCard, IconStripe } from "~/component/icon"
import styles from "./billing-section.module.css"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
@@ -61,6 +61,7 @@ export function BillingSection() {
// Scenario 1: User has not added billing details and has no balance
// const balanceInfo = () => ({
// balance: 0,
// paymentMethodType: null as string | null,
// paymentMethodLast4: null as string | null,
// reload: false,
// reloadError: null as string | null,
@@ -70,6 +71,7 @@ export function BillingSection() {
// Scenario 2: User has not added billing details but has a balance
// const balanceInfo = () => ({
// balance: 1500000000, // $15.00
// paymentMethodType: null as string | null,
// paymentMethodLast4: null as string | null,
// reload: false,
// reloadError: null as string | null,
@@ -79,6 +81,7 @@ export function BillingSection() {
// Scenario 3: User has added billing details (reload enabled)
// const balanceInfo = () => ({
// balance: 750000000, // $7.50
// paymentMethodType: "card",
// paymentMethodLast4: "4242",
// reload: true,
// reloadError: null as string | null,
@@ -88,12 +91,23 @@ export function BillingSection() {
// Scenario 4: User has billing details but reload failed
// const balanceInfo = () => ({
// balance: 250000000, // $2.50
// paymentMethodType: "card",
// paymentMethodLast4: "4242",
// reload: true,
// reloadError: "Your card was declined." as string,
// timeReloadError: new Date(Date.now() - 3600000) as Date // 1 hour ago
// })
// Scenario 5: User has Link payment method
// const balanceInfo = () => ({
// balance: 500000000, // $5.00
// paymentMethodType: "link",
// paymentMethodLast4: null as string | null,
// reload: true,
// reloadError: null as string | null,
// timeReloadError: null as Date | null
// })
const balanceAmount = createMemo(() => {
return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
})
@@ -136,13 +150,25 @@ export function BillingSection() {
<div data-slot="payment">
<div data-slot="credit-card">
<div data-slot="card-icon">
<IconCreditCard style={{ width: "32px", height: "32px" }} />
<Switch fallback={<IconCreditCard style={{ width: "32px", height: "32px" }} />}>
<Match when={balanceInfo()?.paymentMethodType === "link"}>
<IconStripe style={{ width: "32px", height: "32px" }} />
</Match>
</Switch>
</div>
<div data-slot="card-details">
<Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
<span data-slot="secret"></span>
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
</Show>
<Switch
fallback={
<Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
<span data-slot="secret"></span>
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
</Show>
}
>
<Match when={balanceInfo()?.paymentMethodType === "link"}>
<span data-slot="type">Linked to Stripe</span>
</Match>
</Switch>
</div>
</div>
<div data-slot="button-row">
@@ -96,7 +96,7 @@ export function PaymentSection() {
}}
data-slot="receipt-button"
>
view
View
</button>
</td>
</tr>
@@ -52,7 +52,7 @@ export default function () {
}
>
<span data-slot="balance">
Current balance: <b>${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
Current balance <b>${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
</span>
</Show>
</span>
@@ -22,6 +22,22 @@
}
}
[data-slot="beta-notice"] {
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 1.5;
margin-bottom: var(--space-3);
a {
color: var(--color-accent);
text-decoration: none;
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
@@ -81,7 +97,7 @@
display: flex;
gap: var(--space-2);
>button[type="reset"] {
> button[type="reset"] {
align-self: flex-start;
}
}
@@ -207,7 +223,6 @@
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
@@ -217,8 +232,7 @@
th {
&:nth-child(3)
/* Date */
{
/* Date */ {
display: none;
}
}
@@ -226,11 +240,10 @@
td {
&:nth-child(3)
/* Date */
{
/* Date */ {
display: none;
}
}
}
}
}
}
@@ -139,7 +139,7 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
return (
<tr>
<td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
<td data-slot="member-email">{props.member.authEmail ?? props.member.email}</td>
<td data-slot="member-role">
<Show when={store.editing && !isCurrentUser()} fallback={<span>{props.member.role}</span>}>
<RoleDropdown
@@ -253,6 +253,13 @@ export function MemberSection() {
</Show>
</div>
</div>
<div data-slot="beta-notice">
Workspaces are free for teams during the beta.{" "}
<a href="/docs/zen/#for-teams" target="_blank" rel="noopener noreferrer">
Learn more
</a>
.
</div>
<Show when={store.show}>
<form action={inviteMember} method="post" data-slot="create-form">
<div data-slot="input-row">
@@ -34,6 +34,12 @@
color: var(--color-text);
font-family: var(--font-mono);
font-weight: 500;
div {
display: flex;
align-items: center;
gap: 8px;
}
}
&[data-slot="training-data"] {
@@ -88,8 +94,8 @@
}
/* Checked state - track */
input:checked+span {
background-color: #21AD0E;
input:checked + span {
background-color: #21ad0e;
border-color: #148605;
/* Checked state - handle */
@@ -103,7 +109,7 @@
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
input:checked:hover+span {
input:checked:hover + span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
}
@@ -112,16 +118,16 @@
cursor: not-allowed;
}
input:disabled+span {
input:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
input:disabled:checked+span {
input:disabled:checked + span {
opacity: 0.5;
}
input:disabled~span:hover {
input:disabled ~ span:hover {
box-shadow: none;
}
}
@@ -142,7 +148,6 @@
@media (max-width: 40rem) {
[data-slot="models-table-element"] {
th,
td {
padding: var(--space-2) var(--space-3);
@@ -152,8 +157,7 @@
th {
&:nth-child(2)
/* Training Data */
{
/* Training Data */ {
display: none;
}
}
@@ -161,10 +165,9 @@
td {
&:nth-child(2)
/* Training Data */
{
/* Training Data */ {
display: none;
}
}
}
}
}
@@ -2,16 +2,28 @@ import { Model } from "@opencode-ai/console-core/model.js"
import { query, action, useParams, createAsync, json } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { ZenModel } from "@opencode-ai/console-core/model.js"
import { ZenData } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css"
import { querySessionInfo } from "../common"
import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon"
const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba"
if (modelId.startsWith("grok")) return "xAI"
return "Stealth"
}
const getModelsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return {
all: Object.entries(ZenModel.list())
.filter(([id, _model]) => !["claude-3-5-haiku", "qwen3-max"].includes(id))
all: Object.entries(ZenData.list().models)
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
.filter(([id, _model]) => !id.startsWith("an-"))
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
.map(([id, model]) => ({ id, name: model.name })),
disabled: await Model.listDisabled(),
@@ -42,13 +54,21 @@ export function ModelSection() {
const params = useParams()
const modelsInfo = createAsync(() => getModelsInfo(params.id))
const userInfo = createAsync(() => querySessionInfo(params.id))
const modelsWithLab = createMemo(() => {
const info = modelsInfo()
if (!info) return []
return info.all.map((model) => ({
...model,
lab: getModelLab(model.id),
}))
})
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Models</h2>
<p>
Manage which models workspace members can access. Requests will fail if a member tries to use a disabled
model.{userInfo()?.isAdmin ? "" : " To use a disabled model, contact your workspaces admin."}
Manage which models workspace members can access. <a href="/docs/zen#pricing ">Learn more</a>.
</p>
</div>
<div data-slot="models-list">
@@ -58,16 +78,40 @@ export function ModelSection() {
<thead>
<tr>
<th>Model</th>
<th></th>
<th>Enabled</th>
</tr>
</thead>
<tbody>
<For each={modelsInfo()!.all}>
{({ id, name }) => {
<For each={modelsWithLab()}>
{({ id, name, lab }) => {
const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(id))
return (
<tr data-slot="model-row" data-disabled={!isEnabled()}>
<td data-slot="model-name">{name}</td>
<td data-slot="model-name">
<div>
{(() => {
switch (lab) {
case "OpenAI":
return <IconOpenAI width={16} height={16} />
case "Anthropic":
return <IconAnthropic width={16} height={16} />
case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} />
case "Z.ai":
return <IconZai width={16} height={16} />
case "Alibaba":
return <IconAlibaba width={16} height={16} />
case "xAI":
return <IconXai width={16} height={16} />
default:
return <IconStealth width={16} height={16} />
}
})()}
<span>{name}</span>
</div>
</td>
<td data-slot="model-lab">{lab}</td>
<td data-slot="model-toggle">
<form action={updateModel} method="post">
<input type="hidden" name="model" value={id} />
@@ -3,6 +3,10 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
import { action, query } from "@solidjs/router"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { User } from "@opencode-ai/console-core/user.js"
import { and, Database, desc, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
export function formatDateForTable(date: Date) {
const options: Intl.DateTimeFormatOptions = {
@@ -30,6 +34,29 @@ export function formatDateUTC(date: Date) {
return date.toLocaleDateString("en-US", options)
}
export async function getLastSeenWorkspaceID() {
"use server"
return withActor(async () => {
const actor = Actor.assert("account")
return Database.use(async (tx) =>
tx
.select({ id: WorkspaceTable.id })
.from(UserTable)
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.where(
and(
eq(UserTable.accountID, actor.properties.accountID),
isNull(UserTable.timeDeleted),
isNull(WorkspaceTable.timeDeleted),
),
)
.orderBy(desc(UserTable.timeSeen))
.limit(1)
.then((x) => x[0]?.id),
)
})
}
export const querySessionInfo = query(async (workspaceID: string) => {
"use server"
return withActor(() => {
+18 -14
View File
@@ -10,7 +10,7 @@ import { Resource } from "@opencode-ai/console-resource"
import { Billing } from "../../../../core/src/billing"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ZenModel } from "@opencode-ai/console-core/model.js"
import { ZenData } from "@opencode-ai/console-core/model.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
@@ -39,7 +39,8 @@ export async function handler(
class UserLimitError extends Error {}
class ModelError extends Error {}
type Model = z.infer<typeof ZenModel.ModelSchema>
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type Model = ZenData["models"][string]
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
@@ -66,8 +67,9 @@ export async function handler(
session: input.request.headers.get("x-opencode-session"),
request: input.request.headers.get("x-opencode-request"),
})
const modelInfo = validateModel(body.model)
const providerInfo = selectProvider(modelInfo)
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, body.model)
const providerInfo = selectProvider(zenData, modelInfo)
const authInfo = await authenticate(modelInfo, providerInfo)
validateBilling(modelInfo, authInfo)
validateModelSettings(authInfo)
@@ -211,27 +213,29 @@ export async function handler(
)
}
function validateModel(reqModel: string) {
const json = JSON.parse(Resource.ZEN_MODELS.value)
const allModels = ZenModel.ModelsSchema.parse(json)
if (!(reqModel in allModels)) {
function validateModel(zenData: ZenData, reqModel: string) {
if (!(reqModel in zenData.models)) {
throw new ModelError(`Model ${reqModel} not supported`)
}
const modelId = reqModel as keyof typeof allModels
const modelData = allModels[modelId]
const modelId = reqModel as keyof typeof zenData.models
const modelData = zenData.models[modelId]
logger.metric({ model: modelId })
return { id: modelId, ...modelData }
}
function selectProvider(model: Awaited<ReturnType<typeof validateModel>>) {
function selectProvider(zenData: ZenData, model: Awaited<ReturnType<typeof validateModel>>) {
const providers = model.providers
.filter((provider) => !provider.disabled)
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
return providers[Math.floor(Math.random() * providers.length)]
const provider = providers[Math.floor(Math.random() * providers.length)]
if (!(provider.id in zenData.providers)) {
throw new ModelError(`Provider ${provider.id} not supported`)
}
return { ...provider, ...zenData.providers[provider.id] }
}
async function authenticate(
@@ -1,4 +1,5 @@
import "./index.css"
import { createAsync, query, redirect } from "@solidjs/router"
import { Title, Meta, Link } from "@solidjs/meta"
import { HttpHeader } from "@solidjs/start"
import zenLogoLight from "../../asset/zen-ornate-light.svg"
@@ -15,8 +16,16 @@ import { Faq } from "~/component/faq"
import { Legal } from "~/component/legal"
import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { getLastSeenWorkspaceID } from "../workspace/common"
const checkLoggedIn = query(async () => {
"use server"
const workspaceID = await getLastSeenWorkspaceID()
if (workspaceID) throw redirect(`/workspace/${workspaceID}`)
}, "checkLoggedIn.get")
export default function Home() {
createAsync(() => checkLoggedIn())
return (
<main data-page="zen">
<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
@@ -47,11 +47,17 @@ export function POST(input: APIEvent) {
usage = json.usage
},
getStreamUsage: () => usage,
normalizeUsage: (usage: Usage) => ({
inputTokens: usage.prompt_tokens ?? 0,
outputTokens: usage.completion_tokens ?? 0,
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? undefined,
cacheReadTokens: usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined,
}),
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.prompt_tokens ?? 0
const outputTokens = usage.completion_tokens ?? 0
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens: outputTokens - (reasoningTokens ?? 0),
reasoningTokens,
cacheReadTokens,
}
},
})
}
+1
View File
@@ -0,0 +1 @@
script/scrap.ts
@@ -0,0 +1 @@
ALTER TABLE `billing` ADD `payment_method_type` varchar(32);
@@ -0,0 +1,10 @@
CREATE TABLE `auth` (
`id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`provider` enum('email','github','google') NOT NULL,
`subject` varchar(255) NOT NULL,
`account_id` varchar(30) NOT NULL,
CONSTRAINT `provider` UNIQUE(`provider`,`subject`)
);
@@ -0,0 +1,3 @@
ALTER TABLE `account` DROP INDEX `email`;--> statement-breakpoint
CREATE INDEX `account_id` ON `auth` (`account_id`);--> statement-breakpoint
ALTER TABLE `account` DROP COLUMN `email`;
@@ -0,0 +1,2 @@
ALTER TABLE `account` ADD PRIMARY KEY(`id`);--> statement-breakpoint
ALTER TABLE `auth` ADD PRIMARY KEY(`id`);
@@ -0,0 +1,893 @@
{
"version": "5",
"dialect": "mysql",
"id": "91ef8fda-ca96-4a3f-af29-dd6ae7136398",
"prevId": "b2406421-f22d-4153-a2a4-6deafe70ee54",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email": {
"name": "email",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraint": {}
},
"billing": {
"name": "billing",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_id": {
"name": "payment_method_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_type": {
"name": "payment_method_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_last4": {
"name": "payment_method_last4",
"type": "varchar(4)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monthly_limit": {
"name": "monthly_limit",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monthly_usage": {
"name": "monthly_usage",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_monthly_usage_updated": {
"name": "time_monthly_usage_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload": {
"name": "reload",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload_error": {
"name": "reload_error",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_reload_error": {
"name": "time_reload_error",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_reload_locked_till": {
"name": "time_reload_locked_till",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"global_customer_id": {
"name": "global_customer_id",
"columns": [
"customer_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"billing_workspace_id_id_pk": {
"name": "billing_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"payment": {
"name": "payment",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"invoice_id": {
"name": "invoice_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_id": {
"name": "payment_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"amount": {
"name": "amount",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_refunded": {
"name": "time_refunded",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"payment_workspace_id_id_pk": {
"name": "payment_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"usage": {
"name": "usage",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"input_tokens": {
"name": "input_tokens",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"output_tokens": {
"name": "output_tokens",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reasoning_tokens": {
"name": "reasoning_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_read_tokens": {
"name": "cache_read_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_write_5m_tokens": {
"name": "cache_write_5m_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_write_1h_tokens": {
"name": "cache_write_1h_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cost": {
"name": "cost",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"usage_workspace_id_id_pk": {
"name": "usage_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"key": {
"name": "key",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_used": {
"name": "time_used",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"global_key": {
"name": "global_key",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"key_workspace_id_id_pk": {
"name": "key_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"model": {
"name": "model",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"model_workspace_model": {
"name": "model_workspace_model",
"columns": [
"workspace_id",
"model"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"model_workspace_id_id_pk": {
"name": "model_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"provider": {
"name": "provider",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credentials": {
"name": "credentials",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"workspace_provider": {
"name": "workspace_provider",
"columns": [
"workspace_id",
"provider"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"provider_workspace_id_id_pk": {
"name": "provider_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_seen": {
"name": "time_seen",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"color": {
"name": "color",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('admin','member')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monthly_limit": {
"name": "monthly_limit",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monthly_usage": {
"name": "monthly_usage",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_monthly_usage_updated": {
"name": "time_monthly_usage_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_account_id": {
"name": "user_account_id",
"columns": [
"workspace_id",
"account_id"
],
"isUnique": true
},
"user_email": {
"name": "user_email",
"columns": [
"workspace_id",
"email"
],
"isUnique": true
},
"global_account_id": {
"name": "global_account_id",
"columns": [
"account_id"
],
"isUnique": false
},
"global_email": {
"name": "global_email",
"columns": [
"email"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_workspace_id_id_pk": {
"name": "user_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"workspace": {
"name": "workspace",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"slug": {
"name": "slug",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"workspace_id": {
"name": "workspace_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
@@ -0,0 +1,963 @@
{
"version": "5",
"dialect": "mysql",
"id": "34706440-26d7-43f5-9b39-815aa912e5ef",
"prevId": "91ef8fda-ca96-4a3f-af29-dd6ae7136398",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email": {
"name": "email",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraint": {}
},
"auth": {
"name": "auth",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "enum('email','github','google')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"provider": {
"name": "provider",
"columns": [
"provider",
"subject"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraint": {}
},
"billing": {
"name": "billing",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_id": {
"name": "payment_method_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_type": {
"name": "payment_method_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_last4": {
"name": "payment_method_last4",
"type": "varchar(4)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monthly_limit": {
"name": "monthly_limit",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monthly_usage": {
"name": "monthly_usage",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_monthly_usage_updated": {
"name": "time_monthly_usage_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload": {
"name": "reload",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload_error": {
"name": "reload_error",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_reload_error": {
"name": "time_reload_error",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_reload_locked_till": {
"name": "time_reload_locked_till",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"global_customer_id": {
"name": "global_customer_id",
"columns": [
"customer_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"billing_workspace_id_id_pk": {
"name": "billing_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"payment": {
"name": "payment",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"invoice_id": {
"name": "invoice_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_id": {
"name": "payment_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"amount": {
"name": "amount",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_refunded": {
"name": "time_refunded",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"payment_workspace_id_id_pk": {
"name": "payment_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"usage": {
"name": "usage",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"input_tokens": {
"name": "input_tokens",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"output_tokens": {
"name": "output_tokens",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reasoning_tokens": {
"name": "reasoning_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_read_tokens": {
"name": "cache_read_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_write_5m_tokens": {
"name": "cache_write_5m_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_write_1h_tokens": {
"name": "cache_write_1h_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cost": {
"name": "cost",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"usage_workspace_id_id_pk": {
"name": "usage_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"key": {
"name": "key",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_used": {
"name": "time_used",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"global_key": {
"name": "global_key",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"key_workspace_id_id_pk": {
"name": "key_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"model": {
"name": "model",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"model_workspace_model": {
"name": "model_workspace_model",
"columns": [
"workspace_id",
"model"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"model_workspace_id_id_pk": {
"name": "model_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"provider": {
"name": "provider",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credentials": {
"name": "credentials",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"workspace_provider": {
"name": "workspace_provider",
"columns": [
"workspace_id",
"provider"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"provider_workspace_id_id_pk": {
"name": "provider_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_seen": {
"name": "time_seen",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"color": {
"name": "color",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('admin','member')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monthly_limit": {
"name": "monthly_limit",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monthly_usage": {
"name": "monthly_usage",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_monthly_usage_updated": {
"name": "time_monthly_usage_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_account_id": {
"name": "user_account_id",
"columns": [
"workspace_id",
"account_id"
],
"isUnique": true
},
"user_email": {
"name": "user_email",
"columns": [
"workspace_id",
"email"
],
"isUnique": true
},
"global_account_id": {
"name": "global_account_id",
"columns": [
"account_id"
],
"isUnique": false
},
"global_email": {
"name": "global_email",
"columns": [
"email"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_workspace_id_id_pk": {
"name": "user_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"workspace": {
"name": "workspace",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"slug": {
"name": "slug",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"workspace_id": {
"name": "workspace_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
@@ -0,0 +1,955 @@
{
"version": "5",
"dialect": "mysql",
"id": "10169105-4545-4894-838b-004c0a42c584",
"prevId": "34706440-26d7-43f5-9b39-815aa912e5ef",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraint": {}
},
"auth": {
"name": "auth",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "enum('email','github','google')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"provider": {
"name": "provider",
"columns": [
"provider",
"subject"
],
"isUnique": true
},
"account_id": {
"name": "account_id",
"columns": [
"account_id"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraint": {}
},
"billing": {
"name": "billing",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_id": {
"name": "payment_method_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_type": {
"name": "payment_method_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_last4": {
"name": "payment_method_last4",
"type": "varchar(4)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monthly_limit": {
"name": "monthly_limit",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monthly_usage": {
"name": "monthly_usage",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_monthly_usage_updated": {
"name": "time_monthly_usage_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload": {
"name": "reload",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload_error": {
"name": "reload_error",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_reload_error": {
"name": "time_reload_error",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_reload_locked_till": {
"name": "time_reload_locked_till",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"global_customer_id": {
"name": "global_customer_id",
"columns": [
"customer_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"billing_workspace_id_id_pk": {
"name": "billing_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"payment": {
"name": "payment",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"invoice_id": {
"name": "invoice_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_id": {
"name": "payment_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"amount": {
"name": "amount",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_refunded": {
"name": "time_refunded",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"payment_workspace_id_id_pk": {
"name": "payment_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"usage": {
"name": "usage",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"input_tokens": {
"name": "input_tokens",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"output_tokens": {
"name": "output_tokens",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reasoning_tokens": {
"name": "reasoning_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_read_tokens": {
"name": "cache_read_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_write_5m_tokens": {
"name": "cache_write_5m_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_write_1h_tokens": {
"name": "cache_write_1h_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cost": {
"name": "cost",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"usage_workspace_id_id_pk": {
"name": "usage_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"key": {
"name": "key",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_used": {
"name": "time_used",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"global_key": {
"name": "global_key",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"key_workspace_id_id_pk": {
"name": "key_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"model": {
"name": "model",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"model_workspace_model": {
"name": "model_workspace_model",
"columns": [
"workspace_id",
"model"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"model_workspace_id_id_pk": {
"name": "model_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"provider": {
"name": "provider",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credentials": {
"name": "credentials",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"workspace_provider": {
"name": "workspace_provider",
"columns": [
"workspace_id",
"provider"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"provider_workspace_id_id_pk": {
"name": "provider_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_seen": {
"name": "time_seen",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"color": {
"name": "color",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('admin','member')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monthly_limit": {
"name": "monthly_limit",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monthly_usage": {
"name": "monthly_usage",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_monthly_usage_updated": {
"name": "time_monthly_usage_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_account_id": {
"name": "user_account_id",
"columns": [
"workspace_id",
"account_id"
],
"isUnique": true
},
"user_email": {
"name": "user_email",
"columns": [
"workspace_id",
"email"
],
"isUnique": true
},
"global_account_id": {
"name": "global_account_id",
"columns": [
"account_id"
],
"isUnique": false
},
"global_email": {
"name": "global_email",
"columns": [
"email"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_workspace_id_id_pk": {
"name": "user_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"workspace": {
"name": "workspace",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"slug": {
"name": "slug",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"workspace_id": {
"name": "workspace_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
@@ -0,0 +1,969 @@
{
"version": "5",
"dialect": "mysql",
"id": "5470c8b4-296d-47bd-85a7-88cfd3b71434",
"prevId": "10169105-4545-4894-838b-004c0a42c584",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"account_id_pk": {
"name": "account_id_pk",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"auth": {
"name": "auth",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "enum('email','github','google')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"provider": {
"name": "provider",
"columns": [
"provider",
"subject"
],
"isUnique": true
},
"account_id": {
"name": "account_id",
"columns": [
"account_id"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"auth_id_pk": {
"name": "auth_id_pk",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"billing": {
"name": "billing",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_id": {
"name": "payment_method_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_type": {
"name": "payment_method_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_method_last4": {
"name": "payment_method_last4",
"type": "varchar(4)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monthly_limit": {
"name": "monthly_limit",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monthly_usage": {
"name": "monthly_usage",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_monthly_usage_updated": {
"name": "time_monthly_usage_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload": {
"name": "reload",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reload_error": {
"name": "reload_error",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_reload_error": {
"name": "time_reload_error",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_reload_locked_till": {
"name": "time_reload_locked_till",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"global_customer_id": {
"name": "global_customer_id",
"columns": [
"customer_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"billing_workspace_id_id_pk": {
"name": "billing_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"payment": {
"name": "payment",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"invoice_id": {
"name": "invoice_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_id": {
"name": "payment_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"amount": {
"name": "amount",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_refunded": {
"name": "time_refunded",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"payment_workspace_id_id_pk": {
"name": "payment_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"usage": {
"name": "usage",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"input_tokens": {
"name": "input_tokens",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"output_tokens": {
"name": "output_tokens",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reasoning_tokens": {
"name": "reasoning_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_read_tokens": {
"name": "cache_read_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_write_5m_tokens": {
"name": "cache_write_5m_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cache_write_1h_tokens": {
"name": "cache_write_1h_tokens",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cost": {
"name": "cost",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"usage_workspace_id_id_pk": {
"name": "usage_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"key": {
"name": "key",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_used": {
"name": "time_used",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"global_key": {
"name": "global_key",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"key_workspace_id_id_pk": {
"name": "key_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"model": {
"name": "model",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"model_workspace_model": {
"name": "model_workspace_model",
"columns": [
"workspace_id",
"model"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"model_workspace_id_id_pk": {
"name": "model_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"provider": {
"name": "provider",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credentials": {
"name": "credentials",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"workspace_provider": {
"name": "workspace_provider",
"columns": [
"workspace_id",
"provider"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"provider_workspace_id_id_pk": {
"name": "provider_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_seen": {
"name": "time_seen",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"color": {
"name": "color",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('admin','member')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monthly_limit": {
"name": "monthly_limit",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monthly_usage": {
"name": "monthly_usage",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time_monthly_usage_updated": {
"name": "time_monthly_usage_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_account_id": {
"name": "user_account_id",
"columns": [
"workspace_id",
"account_id"
],
"isUnique": true
},
"user_email": {
"name": "user_email",
"columns": [
"workspace_id",
"email"
],
"isUnique": true
},
"global_account_id": {
"name": "global_account_id",
"columns": [
"account_id"
],
"isUnique": false
},
"global_email": {
"name": "global_email",
"columns": [
"email"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_workspace_id_id_pk": {
"name": "user_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"workspace": {
"name": "workspace",
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_created": {
"name": "time_created",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"time_updated": {
"name": "time_updated",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
},
"time_deleted": {
"name": "time_deleted",
"type": "timestamp(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"slug": {
"name": "slug",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"workspace_id": {
"name": "workspace_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
@@ -232,6 +232,34 @@
"when": 1759976329502,
"tag": "0032_white_doctor_doom",
"breakpoints": true
},
{
"idx": 33,
"version": "5",
"when": 1760637384217,
"tag": "0033_cynical_jack_flag",
"breakpoints": true
},
{
"idx": 34,
"version": "5",
"when": 1760651120251,
"tag": "0034_short_bulldozer",
"breakpoints": true
},
{
"idx": 35,
"version": "5",
"when": 1760666253896,
"tag": "0035_narrow_blindfold",
"breakpoints": true
},
{
"idx": 36,
"version": "5",
"when": 1760668952329,
"tag": "0036_slimy_energizer",
"breakpoints": true
}
]
}
+9 -5
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "0.15.0",
"version": "0.15.9",
"private": true,
"type": "module",
"dependencies": {
@@ -24,18 +24,22 @@
"db": "sst shell drizzle-kit",
"db-dev": "sst shell --stage dev -- drizzle-kit",
"db-prod": "sst shell --stage production -- drizzle-kit",
"shell": "sst shell -- bun",
"shell-dev": "sst shell --stage dev -- bun",
"shell-prod": "sst shell --stage production -- bun",
"update-models": "script/update-models.ts",
"promote-models-to-dev": "script/promote-models.ts dev",
"promote-models-to-prod": "script/promote-models.ts production",
"typecheck": "tsc --noEmit"
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "4.20251008.0",
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0",
"@types/node": "24.7.1",
"@types/node": "catalog:",
"drizzle-kit": "0.30.5",
"mysql2": "3.14.4",
"typescript": "catalog:"
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
@@ -2,7 +2,7 @@
import { $ } from "bun"
import path from "path"
import { ZenModel } from "../src/model"
import { ZenData } from "../src/model"
const stage = process.argv[2]
if (!stage) throw new Error("Stage is required")
@@ -18,7 +18,7 @@ const value = ret
if (!value) throw new Error("ZEN_MODELS not found")
// validate value
ZenModel.ModelsSchema.parse(JSON.parse(value))
ZenData.validate(JSON.parse(value))
// update the secret
await $`bun sst secret set ZEN_MODELS ${value} --stage ${stage}`
@@ -3,7 +3,7 @@
import { $ } from "bun"
import path from "path"
import os from "os"
import { ZenModel } from "../src/model"
import { ZenData } from "../src/model"
const root = path.resolve(process.cwd(), "..", "..", "..")
const models = await $`bun sst secret list`.cwd(root).text()
@@ -26,7 +26,7 @@ console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close
await $`vim ${tempFile.name}`
const newValue = JSON.parse(await tempFile.text())
ZenModel.ModelsSchema.parse(newValue)
ZenData.validate(newValue)
// update the secret
await $`bun sst secret set ZEN_MODELS ${JSON.stringify(newValue)}`
-13
View File
@@ -8,7 +8,6 @@ import { AccountTable } from "./schema/account.sql"
export namespace Account {
export const create = fn(
z.object({
email: z.string().email(),
id: z.string().optional(),
}),
async (input) =>
@@ -16,7 +15,6 @@ export namespace Account {
const id = input.id ?? Identifier.create("account")
await tx.insert(AccountTable).values({
id,
email: input.email,
})
return id
}),
@@ -32,15 +30,4 @@ export namespace Account {
.then((rows) => rows[0])
}),
)
export const fromEmail = fn(z.string().email(), async (email) =>
Database.transaction(async (tx) => {
return tx
.select()
.from(AccountTable)
.where(eq(AccountTable.email, email))
.execute()
.then((rows) => rows[0])
}),
)
}
+2 -1
View File
@@ -27,6 +27,7 @@ export namespace Billing {
.select({
customerID: BillingTable.customerID,
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4,
balance: BillingTable.balance,
reload: BillingTable.reload,
@@ -172,7 +173,7 @@ export namespace Billing {
const user = Actor.assert("user")
const { successUrl, cancelUrl } = input
const email = await User.getAccountEmail(user.properties.userID)
const email = await User.getAuthEmail(user.properties.userID)
const customer = await Billing.get()
const session = await Billing.stripe().checkout.sessions.create({
mode: "payment",
+1
View File
@@ -4,6 +4,7 @@ import { z } from "zod"
export namespace Identifier {
const prefixes = {
account: "acc",
auth: "aut",
billing: "bil",
key: "key",
model: "mod",
+3 -4
View File
@@ -4,9 +4,8 @@ import { Actor } from "./actor"
import { and, Database, eq, isNull, sql } from "./drizzle"
import { Identifier } from "./identifier"
import { KeyTable } from "./schema/key.sql"
import { AccountTable } from "./schema/account.sql"
import { UserTable } from "./schema/user.sql"
import { User } from "./user"
import { AuthTable } from "./schema/auth.sql"
export namespace Key {
export const list = fn(z.void(), async () => {
@@ -18,11 +17,11 @@ export namespace Key {
key: KeyTable.key,
timeUsed: KeyTable.timeUsed,
userID: KeyTable.userID,
email: AccountTable.email,
email: AuthTable.subject,
})
.from(KeyTable)
.innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
.innerJoin(AccountTable, eq(UserTable.accountID, AccountTable.id))
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.where(
and(
...[
+20 -7
View File
@@ -7,7 +7,7 @@ import { fn } from "./util/fn"
import { Actor } from "./actor"
import { Resource } from "@opencode-ai/console-resource"
export namespace ZenModel {
export namespace ZenData {
const ModelCostSchema = z.object({
input: z.number(),
output: z.number(),
@@ -16,7 +16,7 @@ export namespace ZenModel {
cacheWrite1h: z.number().optional(),
})
export const ModelSchema = z.object({
const ModelSchema = z.object({
name: z.string(),
cost: ModelCostSchema,
cost200K: ModelCostSchema.optional(),
@@ -24,19 +24,32 @@ export namespace ZenModel {
providers: z.array(
z.object({
id: z.string(),
api: z.string(),
apiKey: z.string(),
model: z.string(),
weight: z.number().optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
disabled: z.boolean().optional(),
}),
),
})
export const ModelsSchema = z.record(z.string(), ModelSchema)
const ProviderSchema = z.object({
api: z.string(),
apiKey: z.string(),
headerMappings: z.record(z.string(), z.string()).optional(),
})
export const list = fn(z.void(), () => ModelsSchema.parse(JSON.parse(Resource.ZEN_MODELS.value)))
const ModelsSchema = z.object({
models: z.record(z.string(), ModelSchema),
providers: z.record(z.string(), ProviderSchema),
})
export const validate = fn(ModelsSchema, (input) => {
return input
})
export const list = fn(z.void(), () => {
const json = JSON.parse(Resource.ZEN_MODELS.value)
return ModelsSchema.parse(json)
})
}
export namespace Model {
@@ -1,4 +1,4 @@
import { mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { mysqlTable, primaryKey } from "drizzle-orm/mysql-core"
import { id, timestamps } from "../drizzle/types"
export const AccountTable = mysqlTable(
@@ -6,7 +6,6 @@ export const AccountTable = mysqlTable(
{
id: id(),
...timestamps,
email: varchar("email", { length: 255 }).notNull(),
},
(table) => [uniqueIndex("email").on(table.email)],
(table) => [primaryKey({ columns: [table.id] })],
)
@@ -0,0 +1,20 @@
import { index, mysqlEnum, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { id, timestamps, ulid } from "../drizzle/types"
export const AuthProvider = ["email", "github", "google"] as const
export const AuthTable = mysqlTable(
"auth",
{
id: id(),
...timestamps,
provider: mysqlEnum("provider", AuthProvider).notNull(),
subject: varchar("subject", { length: 255 }).notNull(),
accountID: ulid("account_id").notNull(),
},
(table) => [
primaryKey({ columns: [table.id] }),
uniqueIndex("provider").on(table.provider, table.subject),
index("account_id").on(table.accountID),
],
)
@@ -9,6 +9,7 @@ export const BillingTable = mysqlTable(
...timestamps,
customerID: varchar("customer_id", { length: 255 }),
paymentMethodID: varchar("payment_method_id", { length: 255 }),
paymentMethodType: varchar("payment_method_type", { length: 32 }),
paymentMethodLast4: varchar("payment_method_last4", { length: 4 }),
balance: bigint("balance", { mode: "number" }).notNull(),
monthlyLimit: int("monthly_limit"),
+23 -16
View File
@@ -7,11 +7,10 @@ import { Actor } from "./actor"
import { Identifier } from "./identifier"
import { render } from "@jsx-email/render"
import { AWS } from "./aws"
import { Account } from "./account"
import { AccountTable } from "./schema/account.sql"
import { Key } from "./key"
import { KeyTable } from "./schema/key.sql"
import { WorkspaceTable } from "./schema/workspace.sql"
import { AuthTable } from "./schema/auth.sql"
export namespace User {
const assertNotSelf = (id: string) => {
@@ -24,10 +23,10 @@ export namespace User {
tx
.select({
...getTableColumns(UserTable),
accountEmail: AccountTable.email,
authEmail: AuthTable.subject,
})
.from(UserTable)
.leftJoin(AccountTable, eq(UserTable.accountID, AccountTable.id))
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(UserTable.workspaceID, Actor.workspace()), isNull(UserTable.timeDeleted))),
),
)
@@ -42,14 +41,14 @@ export namespace User {
),
)
export const getAccountEmail = fn(z.string(), (id) =>
export const getAuthEmail = fn(z.string(), (id) =>
Database.use((tx) =>
tx
.select({
email: AccountTable.email,
email: AuthTable.subject,
})
.from(UserTable)
.leftJoin(AccountTable, eq(UserTable.accountID, AccountTable.id))
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.id, id)))
.then((rows) => rows[0]?.email),
),
@@ -66,16 +65,24 @@ export namespace User {
const workspaceID = Actor.workspace()
// create user
const account = await Account.fromEmail(email)
const accountID = await Database.use((tx) =>
tx
.select({
accountID: AuthTable.accountID,
})
.from(AuthTable)
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
.then((rows) => rows[0]?.accountID),
)
await Database.use((tx) =>
tx
.insert(UserTable)
.values({
id: Identifier.create("user"),
name: "",
...(account
...(accountID
? {
accountID: account.id,
accountID,
}
: {
email,
@@ -94,12 +101,12 @@ export namespace User {
)
// create api key
if (account) {
if (accountID) {
await Database.use(async (tx) => {
const user = await tx
.select()
.from(UserTable)
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, account.id)))
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, accountID)))
.then((rows) => rows[0])
const key = await tx
@@ -119,11 +126,11 @@ export namespace User {
const emailInfo = await Database.use((tx) =>
tx
.select({
email: AccountTable.email,
inviterEmail: AuthTable.subject,
workspaceName: WorkspaceTable.name,
})
.from(UserTable)
.innerJoin(AccountTable, eq(UserTable.accountID, AccountTable.id))
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, workspaceID))
.where(
and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.id, Actor.assert("user").properties.userID)),
@@ -134,11 +141,11 @@ export namespace User {
const { InviteEmail } = await import("@opencode-ai/console-mail/InviteEmail.jsx")
await AWS.sendEmail({
to: email,
subject: `You've been invited to join the ${emailInfo.workspaceName} workspace on OpenCode Console`,
subject: `You've been invited to join the ${emailInfo.workspaceName} workspace on OpenCode`,
body: render(
// @ts-ignore
InviteEmail({
inviter: emailInfo.email,
inviter: emailInfo.inviterEmail,
assetsUrl: `https://opencode.ai/email`,
workspaceID: workspaceID,
workspaceName: emailInfo.workspaceName,
+5 -4
View File
@@ -1,18 +1,19 @@
{
"name": "@opencode-ai/console-function",
"version": "0.15.0",
"version": "0.15.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "4.20251008.0",
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
"@types/node": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:"
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
+74 -7
View File
@@ -1,3 +1,4 @@
import type { KVNamespace } from "@cloudflare/workers-types"
import { z } from "zod"
import { issuer } from "@openauthjs/openauth"
import type { Theme } from "@openauthjs/openauth/ui/theme"
@@ -11,9 +12,11 @@ import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource"
import { User } from "@opencode-ai/console-core/user.js"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { and, Database, eq, isNull, or } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
type Env = {
AuthStorage: KVNamespace
@@ -94,12 +97,14 @@ export default {
// }),
},
storage: CloudflareStorage({
// @ts-ignore
namespace: env.AuthStorage,
}),
subjects,
async success(ctx, response) {
console.log(response)
let subject: string | undefined
let email: string | undefined
if (response.provider === "github") {
@@ -110,21 +115,83 @@ export default {
Accept: "application/vnd.github+json",
},
}).then((x) => x.json())) as any
const user = (await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${response.tokenset.access}`,
"User-Agent": "opencode",
Accept: "application/vnd.github+json",
},
}).then((x) => x.json())) as any
subject = user.id.toString()
email = emails.find((x: any) => x.primary && x.verified)?.email
} else if (response.provider === "google") {
if (!response.id.email_verified) throw new Error("Google email not verified")
subject = response.id.sub as string
email = response.id.email as string
} else throw new Error("Unsupported provider")
if (!email) throw new Error("No email found")
if (!subject) throw new Error("No subject found")
let accountID = await Account.fromEmail(email).then((x) => x?.id)
if (!accountID) {
console.log("creating account for", email)
accountID = await Account.create({
email: email!,
})
if (Resource.App.stage !== "production" && !email.endsWith("@anoma.ly")) {
throw new Error("Invalid email")
}
// Get account
const accountID = await (async () => {
const matches = await Database.use(async (tx) =>
tx
.select({
provider: AuthTable.provider,
accountID: AuthTable.accountID,
})
.from(AuthTable)
.where(
or(
and(eq(AuthTable.provider, response.provider), eq(AuthTable.subject, subject)),
and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)),
),
),
)
const idByProvider = matches.find((x) => x.provider === response.provider)?.accountID
const idByEmail = matches.find((x) => x.provider === "email")?.accountID
if (idByProvider && idByEmail) return idByProvider
// create account if not found
let accountID = idByProvider ?? idByEmail
if (!accountID) {
console.log("creating account for", email)
accountID = await Account.create({})
}
await Database.use(async (tx) =>
tx
.insert(AuthTable)
.values([
{
id: Identifier.create("auth"),
accountID,
provider: response.provider,
subject,
},
{
id: Identifier.create("auth"),
accountID,
provider: "email",
subject: email,
},
])
.onDuplicateKeyUpdate({
set: {
timeDeleted: null,
},
}),
)
return accountID
})()
// Get workspace
await Actor.provide("account", { accountID, email }, async () => {
await User.joinInvitedWorkspaces()
const workspaces = await Database.transaction(async (tx) =>
+7 -47
View File
@@ -1,26 +1,10 @@
// @ts-nocheck
import React from "react"
import { Font, Hr as JEHr, Text as JEText, type HrProps, type TextProps } from "@jsx-email/all"
import { DIVIDER_COLOR, SURFACE_DIVIDER_COLOR, textColor } from "./styles"
import { Font, Text as JEText, type TextProps } from "@jsx-email/all"
import { baseText } from "./styles"
export function Text(props: TextProps) {
return <JEText {...props} style={{ ...textColor, ...props.style }} />
}
export function Hr(props: HrProps) {
return <JEHr {...props} style={{ borderTop: `1px solid ${DIVIDER_COLOR}`, ...props.style }} />
}
export function SurfaceHr(props: HrProps) {
return (
<JEHr
{...props}
style={{
borderTop: `1px solid ${SURFACE_DIVIDER_COLOR}`,
...props.style,
}}
/>
)
return <JEText {...props} style={{ ...baseText, ...props.style }} />
}
export function Title({ children }: TitleProps) {
@@ -31,10 +15,6 @@ export function A({ children, ...props }: AProps) {
return React.createElement("a", props, children)
}
export function B({ children, ...props }: AProps) {
return React.createElement("b", props, children)
}
export function Span({ children, ...props }: SpanProps) {
return React.createElement("span", props, children)
}
@@ -47,45 +27,25 @@ export function Fonts({ assetsUrl }: { assetsUrl: string }) {
return (
<>
<Font
fontFamily="IBM Plex Mono"
fontFamily="JetBrains Mono"
fallbackFontFamily="monospace"
webFont={{
url: `${assetsUrl}/ibm-plex-mono-latin-400.woff2`,
url: `${assetsUrl}/JetBrainsMono-Regular.woff2`,
format: "woff2",
}}
fontWeight="400"
fontStyle="normal"
/>
<Font
fontFamily="IBM Plex Mono"
fontFamily="JetBrains Mono"
fallbackFontFamily="monospace"
webFont={{
url: `${assetsUrl}/ibm-plex-mono-latin-500.woff2`,
url: `${assetsUrl}/JetBrainsMono-Medium.woff2`,
format: "woff2",
}}
fontWeight="500"
fontStyle="normal"
/>
<Font
fontFamily="IBM Plex Mono"
fallbackFontFamily="monospace"
webFont={{
url: `${assetsUrl}/ibm-plex-mono-latin-600.woff2`,
format: "woff2",
}}
fontWeight="600"
fontStyle="normal"
/>
<Font
fontFamily="IBM Plex Mono"
fallbackFontFamily="monospace"
webFont={{
url: `${assetsUrl}/ibm-plex-mono-latin-700.woff2`,
format: "woff2",
}}
fontWeight="700"
fontStyle="normal"
/>
<Font
fontFamily="Rubik"
fallbackFontFamily={["Helvetica", "Arial", "sans-serif"]}
+70 -89
View File
@@ -1,110 +1,91 @@
export const unit = 16;
export const GREY_COLOR = [
"#1A1A2E", //0
"#2F2F41", //1
"#444454", //2
"#585867", //3
"#6D6D7A", //4
"#82828D", //5
"#9797A0", //6
"#ACACB3", //7
"#C1C1C6", //8
"#D5D5D9", //9
"#EAEAEC", //10
"#FFFFFF", //11
];
export const BLUE_COLOR = "#395C6B";
export const DANGER_COLOR = "#ED322C";
export const TEXT_COLOR = GREY_COLOR[0];
export const SECONDARY_COLOR = GREY_COLOR[5];
export const DIMMED_COLOR = GREY_COLOR[7];
export const DIVIDER_COLOR = GREY_COLOR[10];
export const BACKGROUND_COLOR = "#F0F0F1";
export const SURFACE_COLOR = DIVIDER_COLOR;
export const SURFACE_DIVIDER_COLOR = GREY_COLOR[9];
// @ts-nocheck
export const unit = 12
export const PRIMARY_COLOR = "#211E1E"
export const TEXT_COLOR = "#656363"
export const LINK_COLOR = "#007AFF"
export const LINK_BACKGROUND_COLOR = "#F9F8F8"
export const BACKGROUND_COLOR = "#F0F0F1"
export const SURFACE_DIVIDER_COLOR = "#D5D5D9"
export const body = {
background: BACKGROUND_COLOR,
};
}
export const container = {
minWidth: "600px",
};
export const medium = {
fontWeight: 500,
};
export const danger = {
color: DANGER_COLOR,
};
padding: "64px 0px",
}
export const frame = {
padding: `${unit * 1.5}px`,
padding: `${unit * 2}px`,
border: `1px solid ${SURFACE_DIVIDER_COLOR}`,
background: "#FFF",
borderRadius: "6px",
boxShadow: `0 1px 2px rgba(0,0,0,0.03),
0 2px 4px rgba(0,0,0,0.03),
0 2px 6px rgba(0,0,0,0.03)`,
};
}
export const textColor = {
export const baseText = {
fontFamily: "JetBrains Mono, monospace",
}
export const headingText = {
color: PRIMARY_COLOR,
fontSize: "16px",
fontStyle: "normal",
fontWeight: 500,
lineHeight: "normal",
}
export const contentText = {
color: TEXT_COLOR,
};
fontSize: "14px",
fontStyle: "normal",
fontWeight: 400,
lineHeight: "180%",
}
export const code = {
fontFamily: "IBM Plex Mono, monospace",
};
export const buttonText = {
color: "#FDFCFC",
fontSize: "16px",
fontWeight: 500,
margin: 0,
padding: 0,
display: "inline-flex",
alignItems: "center",
gap: "12px",
}
export const headingHr = {
margin: `${unit}px 0`,
};
export const buttonPrimary = {
...code,
padding: "12px 18px",
color: "#FFF",
export const linkText = {
color: LINK_COLOR,
fontSize: "14px",
fontStyle: "normal",
fontWeight: 400,
lineHeight: "150%",
textDecorationLine: "underline",
textDecorationStyle: "solid" as const,
textDecorationSkipInk: "auto" as const,
textDecorationThickness: "auto",
textUnderlineOffset: "auto",
textUnderlinePosition: "from-font",
borderRadius: "4px",
background: BLUE_COLOR,
fontSize: "12px",
fontWeight: 500,
};
background: LINK_BACKGROUND_COLOR,
padding: "8px 12px",
textAlign: "center" as const,
}
export const compactText = {
margin: "0 0 2px",
};
export const contentHighlightText = {
color: PRIMARY_COLOR,
}
export const breadcrumb = {
fontSize: "14px",
color: SECONDARY_COLOR,
};
export const breadcrumbColonSeparator = {
padding: " 0 4px",
color: DIMMED_COLOR,
};
export const breadcrumbSeparator = {
color: DIVIDER_COLOR,
};
export const heading = {
fontSize: "22px",
fontWeight: 500,
};
export const sectionLabel = {
...code,
...compactText,
letterSpacing: "0.5px",
fontSize: "13px",
fontWeight: 500,
color: DIMMED_COLOR,
};
export const footerLink = {
fontSize: "14px",
};
export const button = {
display: "inline-grid",
padding: "8px 12px 8px 20px",
justifyContent: "center",
alignItems: "center",
gap: "8px",
flexShrink: "0",
borderRadius: "4px",
backgroundColor: PRIMARY_COLOR,
}
@@ -1,26 +1,21 @@
// @ts-nocheck
import React from "react"
import { Img, Row, Html, Link, Body, Head, Button, Column, Preview, Section, Container } from "@jsx-email/all"
import { Hr, Text, Fonts, SplitString, Title, A, Span, B } from "../components"
import { Text, Fonts, Title, A, Span } from "../components"
import {
unit,
body,
code,
frame,
medium,
heading,
headingText,
container,
headingHr,
footerLink,
breadcrumb,
compactText,
buttonPrimary,
breadcrumbColonSeparator,
contentText,
button,
contentHighlightText,
linkText,
buttonText,
} from "../styles"
const LOCAL_ASSETS_URL = "/static"
const CONSOLE_URL = "https://opencode.ai/"
const DOC_URL = "https://opencode.ai/docs/zen"
interface InviteEmailProps {
inviter: string
@@ -32,9 +27,8 @@ export const InviteEmail = ({
inviter = "test@anoma.ly",
workspaceID = "wrk_01K6XFY7V53T8XN0A7X8G9BTN3",
workspaceName = "anomaly",
assetsUrl = LOCAL_ASSETS_URL,
assetsUrl = `${CONSOLE_URL}email`,
}: InviteEmailProps) => {
const subject = `You've been invited to join the ${workspaceName} workspace on OpenCode Console`
const messagePlain = `${inviter} invited you to join the ${workspaceName} workspace.`
const url = `${CONSOLE_URL}workspace/${workspaceID}`
return (
@@ -55,50 +49,29 @@ export const InviteEmail = ({
</Column>
</Row>
<Row style={headingHr}>
<Column>
<Hr />
</Column>
</Row>
<Section style={{ padding: `${unit}px 0 0 0` }}>
<Text style={{ ...compactText }}>
<B>{inviter}</B> invited you to join the{" "}
<Link style={medium} href={url}>
<B>{workspaceName}</B>
</Link>{" "}
workspace in the{" "}
<Link style={medium} href={`${CONSOLE_URL}zen`}>
OpenCode Console
</Link>
.
<Section style={{ padding: `${unit * 2}px 0 0 0` }}>
<Text style={headingText}>Join your team's OpenCode workspace</Text>
<Text style={contentText}>
You have been invited by <Span style={contentHighlightText}>{inviter}</Span> to join the{" "}
<Span style={contentHighlightText}>{workspaceName}</Span> workspace on OpenCode.
</Text>
</Section>
<Section style={{ padding: `${unit}px 0 0 0` }}>
<Button style={buttonPrimary} href={url}>
<Span style={code}>Join Workspace</Span>
<Button style={button} href={url}>
<Text style={buttonText}>
Join workspace
<Img width="24" height="24" src={`${assetsUrl}/right-arrow.png`} alt="Arrow right" />
</Text>
</Button>
</Section>
<Row style={headingHr}>
<Column>
<Hr />
</Column>
</Row>
<Row>
<Column>
<Link href={`${CONSOLE_URL}zen`} style={footerLink}>
Console
</Link>
</Column>
<Column align="right">
<Link style={footerLink} href={DOC_URL}>
About
</Link>
</Column>
</Row>
<Section style={{ padding: `${unit}px 0 0 0` }}>
<Text style={contentText}>Button not working? Copy the following link...</Text>
<Link href={url}>
<Text style={linkText}>{url}</Text>
</Link>
</Section>
</Section>
</Container>
</Body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "0.15.0",
"version": "0.15.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-resource",
"dependencies": {
"@cloudflare/workers-types": "^4.20250830.0"
"@cloudflare/workers-types": "catalog:"
},
"exports": {
".": {
-1
View File
@@ -1 +0,0 @@
src/scrap.ts
-15
View File
@@ -1,15 +0,0 @@
{
"name": "@opencode-ai/console-scripts",
"version": "0.15.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"shell": "sst shell -- bun",
"shell-dev": "sst shell --stage dev -- bun",
"shell-prod": "sst shell --stage production -- bun"
},
"dependencies": {
"@opencode-ai/console-core": "workspace:*"
}
}
@@ -1,10 +0,0 @@
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
await Database.use(async (tx) => {
await tx
.update(UsageTable)
.set({ model: "grok-code" })
.where(eq(UsageTable.model, "x-ai/grok-code-fast-1"))
.limit(90000)
})
-9
View File
@@ -1,9 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../../sst-env.d.ts" />
import "sst"
export {}
-9
View File
@@ -1,9 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"]
}
}
+11 -12
View File
@@ -1,22 +1,21 @@
<!doctype html>
<html lang="en" class="h-full bg-background">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.svg" />
<link rel="stylesheet" href="/src/assets/theme.css" />
<title>opencode</title>
<title>OpenCode</title>
</head>
<body class="h-full overscroll-none select-none">
<script>
;(function () {
const savedTheme = localStorage.getItem("theme") || "opencode"
const savedDarkMode = localStorage.getItem("darkMode") !== "false"
document.documentElement.setAttribute("data-theme", savedTheme)
document.documentElement.setAttribute("data-dark", savedDarkMode.toString())
})()
</script>
<body class="overscroll-none select-none text-12-regular">
<!-- <script> -->
<!-- ;(function () { -->
<!-- const savedTheme = localStorage.getItem("theme") || "opencode" -->
<!-- const savedDarkMode = localStorage.getItem("darkMode") !== "false" -->
<!-- document.documentElement.setAttribute("data-theme", savedTheme) -->
<!-- document.documentElement.setAttribute("data-dark", savedDarkMode.toString()) -->
<!-- })() -->
<!-- </script> -->
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
+12 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "0.15.0",
"version": "0.15.9",
"description": "",
"type": "module",
"scripts": {
@@ -8,18 +8,19 @@
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"typecheck": "tsc --noEmit"
"typecheck": "tsgo --noEmit"
},
"license": "MIT",
"devDependencies": {
"@tailwindcss/vite": "4.1.11",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/luxon": "3.7.1",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "^6.0.0",
"@typescript/native-preview": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "^2.11.6"
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@kobalte/core": "catalog:",
@@ -28,9 +29,11 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.3",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "8.0.2",
"diff": "catalog:",
"@opencode-ai/ui": "workspace:*",
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "16.2.0",
@@ -38,9 +41,9 @@
"remeda": "catalog:",
"solid-js": "catalog:",
"shiki": "3.9.2",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3"
"solid-list": "catalog:",
"tailwindcss": "catalog:",
"virtua": "catalog:"
},
"prettier": {
"semi": false,
@@ -1,163 +0,0 @@
import type { Plugin } from "vite"
import { readdir, readFile, writeFile } from "fs/promises"
import { join, resolve } from "path"
interface ThemeDefinition {
$schema?: string
defs?: Record<string, string>
theme: Record<string, any>
}
interface ResolvedThemeColor {
dark: string
light: string
}
class ColorResolver {
private colors: Map<string, any> = new Map()
private visited: Set<string> = new Set()
constructor(defs: Record<string, string> = {}, theme: Record<string, any> = {}) {
Object.entries(defs).forEach(([key, value]) => {
this.colors.set(key, value)
})
Object.entries(theme).forEach(([key, value]) => {
this.colors.set(key, value)
})
}
resolveColor(key: string, value: any): ResolvedThemeColor {
if (this.visited.has(key)) {
throw new Error(`Circular reference detected for color ${key}`)
}
this.visited.add(key)
try {
if (typeof value === "string") {
if (value === "none") return { dark: value, light: value }
if (value.startsWith("#")) {
return { dark: value.toLowerCase(), light: value.toLowerCase() }
}
const resolved = this.resolveReference(value)
return { dark: resolved, light: resolved }
}
if (typeof value === "object" && value !== null) {
const dark = this.resolveColorValue(value.dark || value.light || "#000000")
const light = this.resolveColorValue(value.light || value.dark || "#FFFFFF")
return { dark, light }
}
return { dark: "#000000", light: "#FFFFFF" }
} finally {
this.visited.delete(key)
}
}
private resolveColorValue(value: any): string {
if (typeof value === "string") {
if (value === "none") return value
if (value.startsWith("#")) {
return value.toLowerCase()
}
return this.resolveReference(value)
}
return value
}
private resolveReference(ref: string): string {
const colorValue = this.colors.get(ref)
if (colorValue === undefined) {
throw new Error(`Color reference '${ref}' not found`)
}
if (typeof colorValue === "string") {
if (colorValue === "none") return colorValue
if (colorValue.startsWith("#")) {
return colorValue.toLowerCase()
}
return this.resolveReference(colorValue)
}
return colorValue
}
}
function kebabCase(str: string): string {
return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
}
function parseTheme(themeData: ThemeDefinition): Record<string, ResolvedThemeColor> {
const resolver = new ColorResolver(themeData.defs, themeData.theme)
const colors: Record<string, ResolvedThemeColor> = {}
Object.entries(themeData.theme).forEach(([key, value]) => {
colors[key] = resolver.resolveColor(key, value)
})
return colors
}
async function loadThemes(): Promise<Record<string, Record<string, ResolvedThemeColor>>> {
const themesDir = resolve(__dirname, "../../tui/internal/theme/themes")
const files = await readdir(themesDir)
const themes: Record<string, Record<string, ResolvedThemeColor>> = {}
for (const file of files) {
if (!file.endsWith(".json")) continue
const themeName = file.replace(".json", "")
const themeData: ThemeDefinition = JSON.parse(await readFile(join(themesDir, file), "utf-8"))
themes[themeName] = parseTheme(themeData)
}
return themes
}
function generateCSS(themes: Record<string, Record<string, ResolvedThemeColor>>): string {
let css = `/* Auto-generated theme CSS - Do not edit manually */\n:root {\n`
const defaultTheme = themes["opencode"] || Object.values(themes)[0]
if (defaultTheme) {
Object.entries(defaultTheme).forEach(([key, color]) => {
const cssVar = `--theme-${kebabCase(key)}`
css += ` ${cssVar}: ${color.light};\n`
})
}
css += `}\n\n`
Object.entries(themes).forEach(([themeName, colors]) => {
css += `[data-theme="${themeName}"][data-dark="false"] {\n`
Object.entries(colors).forEach(([key, color]) => {
const cssVar = `--theme-${kebabCase(key)}`
css += ` ${cssVar}: ${color.light};\n`
})
css += `}\n\n`
css += `[data-theme="${themeName}"][data-dark="true"] {\n`
Object.entries(colors).forEach(([key, color]) => {
const cssVar = `--theme-${kebabCase(key)}`
css += ` ${cssVar}: ${color.dark};\n`
})
css += `}\n\n`
})
return css
}
export function generateThemeCSS(): Plugin {
return {
name: "generate-theme-css",
async buildStart() {
try {
console.log("Generating theme CSS...")
const themes = await loadThemes()
const css = generateCSS(themes)
const outputPath = resolve(__dirname, "../src/assets/theme.css")
await writeFile(outputPath, css)
console.log(`✅ Generated theme CSS with ${Object.keys(themes).length} themes`)
console.log(` Output: ${outputPath}`)
} catch (error) {
throw new Error(`Theme CSS generation failed: ${error}`)
}
},
}
}
+117 -233
View File
@@ -1,6 +1,7 @@
import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
import { Tabs } from "@/ui/tabs"
import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
import { Tabs, Tooltip } from "@opencode-ai/ui"
import { Icon } from "@opencode-ai/ui"
import { FileIcon, IconButton } from "@/ui"
import {
DragDropProvider,
DragDropSensors,
@@ -13,30 +14,16 @@ import {
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { LocalFile } from "@/context/local"
import { Code } from "@/components/code"
import PromptForm from "@/components/prompt-form"
import { useLocal, useSDK, useSync } from "@/context"
import { getFilename } from "@/utils"
import { useLocal } from "@/context"
import type { JSX } from "solid-js"
interface EditorPaneProps {
layoutKey: string
timelinePane: string
onFileClick: (file: LocalFile) => void
onOpenModelSelect: () => void
onInputRefChange: (element: HTMLTextAreaElement | null) => void
}
export default function EditorPane(props: EditorPaneProps): JSX.Element {
const [localProps] = splitProps(props, [
"layoutKey",
"timelinePane",
"onFileClick",
"onOpenModelSelect",
"onInputRefChange",
])
const [localProps] = splitProps(props, ["onFileClick"])
const local = useLocal()
const sdk = useSDK()
const sync = useSync()
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
const navigateChange = (dir: 1 | -1) => {
@@ -55,73 +42,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
local.file.close(file.path)
}
const handlePromptSubmit = async (prompt: string) => {
const existingSession = local.layout.visible(localProps.layoutKey, localProps.timelinePane)
? local.session.active()
: undefined
let session = existingSession
if (!session) {
const created = await sdk.session.create()
session = created.data ?? undefined
}
if (!session) return
local.session.setActive(session.id)
local.layout.show(localProps.layoutKey, localProps.timelinePane)
await sdk.session.prompt({
path: { id: session.id },
body: {
agent: local.agent.current()!.name,
model: {
modelID: local.model.current()!.id,
providerID: local.model.current()!.provider.id,
},
parts: [
{
type: "text",
text: prompt,
},
...(local.context.active()
? [
{
type: "file" as const,
mime: "text/plain",
url: `file://${local.context.active()!.absolute}`,
filename: local.context.active()!.name,
source: {
type: "file" as const,
text: {
value: "@" + local.context.active()!.name,
start: 0,
end: 0,
},
path: local.context.active()!.absolute,
},
},
]
: []),
...local.context.all().flatMap((file) => [
{
type: "file" as const,
mime: "text/plain",
url: `file://${sync.absolute(file.path)}${file.selection ? `?start=${file.selection.startLine}&end=${file.selection.endLine}` : ""}`,
filename: getFilename(file.path),
source: {
type: "file" as const,
text: {
value: "@" + getFilename(file.path),
start: 0,
end: 0,
},
path: sync.absolute(file.path),
},
},
]),
],
},
})
}
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
@@ -145,155 +65,119 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
}
return (
<div class="relative flex h-full flex-col">
<Logo size={64} variant="ornate" class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2" />
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs
class="relative grow w-full flex flex-col h-full"
value={local.file.active()?.path}
onChange={handleTabChange}
>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List class="grow">
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
<For each={local.file.opened()}>
{(file) => (
<SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} />
)}
</For>
</SortableProvider>
</Tabs.List>
<div class="shrink-0 h-full flex items-center gap-1 px-2 border-b border-border-subtle/40">
<Show when={local.file.active() && local.file.active()!.content?.diff}>
{(() => {
const activeFile = local.file.active()!
const view = local.file.view(activeFile.path)
return (
<div class="flex items-center gap-1">
<Show when={view !== "raw"}>
<div class="mr-1 flex items-center gap-1">
<Tooltip value="Previous change" placement="bottom">
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
<Icon name="arrow-up" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Next change" placement="bottom">
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
<Icon name="arrow-down" size={14} />
</IconButton>
</Tooltip>
</div>
</Show>
<Tooltip value="Raw" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "raw",
"text-text-muted/70": view !== "raw",
"bg-background-element": view === "raw",
}}
onClick={() => local.file.setView(activeFile.path, "raw")}
>
<Icon name="file-text" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Unified diff" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "diff-unified",
"text-text-muted/70": view !== "diff-unified",
"bg-background-element": view === "diff-unified",
}}
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
>
<Icon name="checklist" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Split diff" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "diff-split",
"text-text-muted/70": view !== "diff-split",
"bg-background-element": view === "diff-split",
}}
onClick={() => local.file.setView(activeFile.path, "diff-split")}
>
<Icon name="columns" size={14} />
</IconButton>
</Tooltip>
</div>
)
})()}
</Show>
<Tooltip
value={local.layout.visible(localProps.layoutKey, localProps.timelinePane) ? "Close pane" : "Open pane"}
placement="bottom"
>
<IconButton
size="xs"
variant="ghost"
onClick={() => local.layout.toggle(localProps.layoutKey, localProps.timelinePane)}
>
<Icon
name={
local.layout.visible(localProps.layoutKey, localProps.timelinePane) ? "close-pane" : "open-pane"
}
size={14}
/>
</IconButton>
</Tooltip>
</div>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={local.file.active()?.path} onChange={handleTabChange}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
<For each={local.file.opened()}>
{(file) => <SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} />}
</For>
</SortableProvider>
</Tabs.List>
<div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
<Show when={local.file.active() && local.file.active()!.content?.diff}>
{(() => {
const activeFile = local.file.active()!
const view = local.file.view(activeFile.path)
return (
<div class="flex items-center gap-1">
<Show when={view !== "raw"}>
<div class="mr-1 flex items-center gap-1">
<Tooltip value="Previous change" placement="bottom">
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
<Icon name="arrow-up" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Next change" placement="bottom">
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
<Icon name="arrow-down" size={14} />
</IconButton>
</Tooltip>
</div>
</Show>
<Tooltip value="Raw" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "raw",
"text-text-muted/70": view !== "raw",
"bg-background-element": view === "raw",
}}
onClick={() => local.file.setView(activeFile.path, "raw")}
>
<Icon name="file-text" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Unified diff" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "diff-unified",
"text-text-muted/70": view !== "diff-unified",
"bg-background-element": view === "diff-unified",
}}
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
>
<Icon name="checklist" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Split diff" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "diff-split",
"text-text-muted/70": view !== "diff-split",
"bg-background-element": view === "diff-split",
}}
onClick={() => local.file.setView(activeFile.path, "diff-split")}
>
<Icon name="columns" size={14} />
</IconButton>
</Tooltip>
</div>
)
})()}
</Show>
</div>
<For each={local.file.opened()}>
{(file) => (
<Tabs.Content value={file.path} class="grow h-full pt-1 select-text">
{(() => {
const view = local.file.view(file.path)
const showRaw = view === "raw" || !file.content?.diff
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
})()}
</Tabs.Content>
)}
</For>
</Tabs>
<DragOverlay>
{(() => {
const id = activeItem()
if (!id) return null
const draggedFile = local.file.node(id)
if (!draggedFile) return null
return (
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
<TabVisual file={draggedFile} />
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
<PromptForm
class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center"
classList={{
"bottom-8": !!local.file.active(),
"bottom-3/8": local.file.active() === undefined,
}}
onSubmit={handlePromptSubmit}
onOpenModelSelect={localProps.onOpenModelSelect}
onInputRefChange={(element) => localProps.onInputRefChange(element ?? null)}
/>
</div>
</div>
<For each={local.file.opened()}>
{(file) => (
<Tabs.Content value={file.path} class="select-text">
{(() => {
const view = local.file.view(file.path)
const showRaw = view === "raw" || !file.content?.diff
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
})()}
</Tabs.Content>
)}
</For>
</Tabs>
<DragOverlay>
{(() => {
const id = activeItem()
if (!id) return null
const draggedFile = local.file.node(id)
if (!draggedFile) return null
return (
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
<TabVisual file={draggedFile} />
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
)
}
@@ -1,6 +1,7 @@
import { useLocal } from "@/context"
import type { LocalFile } from "@/context/local"
import { Collapsible, FileIcon, Tooltip } from "@/ui"
import { Tooltip } from "@opencode-ai/ui"
import { Collapsible, FileIcon } from "@/ui"
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
+1 -1
View File
@@ -16,7 +16,7 @@ export function Markdown(props: { text: string; class?: string }) {
)
return (
<div
class={`min-w-0 max-w-full text-xs overflow-auto no-scrollbar prose ${props.class ?? ""}`}
class={`min-w-0 max-w-full overflow-auto no-scrollbar text-14-regular text-text-base ${props.class ?? ""}`}
innerHTML={html()}
/>
)
@@ -0,0 +1,48 @@
import { Component, createMemo } from "solid-js"
interface ProgressCircleProps {
percentage: number
size?: number
strokeWidth?: number
}
export const ProgressCircle: Component<ProgressCircleProps> = (props) => {
// --- Set default values for props ---
const size = () => props.size || 16
const strokeWidth = () => props.strokeWidth || 3
// --- Constants for SVG calculation ---
const viewBoxSize = 16
const center = viewBoxSize / 2
const radius = () => center - strokeWidth() / 2
const circumference = createMemo(() => 2 * Math.PI * radius())
// --- Reactive Calculation for the progress offset ---
const offset = createMemo(() => {
const clampedPercentage = Math.max(0, Math.min(100, props.percentage || 0))
const progress = clampedPercentage / 100
return circumference() * (1 - progress)
})
return (
<svg
width={size()}
height={size()}
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
fill="none"
class="transform -rotate-90"
>
<circle cx={center} cy={center} r={radius()} class="stroke-border-weak-base" stroke-width={strokeWidth()} />
<circle
cx={center}
cy={center}
r={radius()}
class="stroke-border-active"
stroke-width={strokeWidth()}
stroke-dasharray={circumference().toString()}
stroke-dashoffset={offset()}
style={{ transition: "stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1)" }}
/>
</svg>
)
}
@@ -0,0 +1,164 @@
import type { TextSelection } from "@/context/local"
import { getFilename } from "@/utils"
export interface PromptTextPart {
kind: "text"
value: string
}
export interface PromptAttachmentPart {
kind: "attachment"
token: string
display: string
path: string
selection?: TextSelection
origin: "context" | "active"
}
export interface PromptInterimPart {
kind: "interim"
value: string
leadingSpace: boolean
}
export type PromptContentPart = PromptTextPart | PromptAttachmentPart
export type PromptDisplaySegment =
| { kind: "text"; value: string }
| { kind: "attachment"; part: PromptAttachmentPart; source: string }
| PromptInterimPart
export interface AttachmentCandidate {
origin: "context" | "active"
path: string
selection?: TextSelection
display: string
}
export interface PromptSubmitValue {
text: string
parts: PromptContentPart[]
}
export const mentionPattern = /@([A-Za-z0-9_\-./]+)/g
export const mentionTriggerPattern = /(^|\s)@([A-Za-z0-9_\-./]*)$/
export type PromptSegment = (PromptTextPart | PromptAttachmentPart) & {
start: number
end: number
}
export type PromptAttachmentSegment = PromptAttachmentPart & {
start: number
end: number
}
function pushTextPart(parts: PromptContentPart[], value: string) {
if (!value) return
const last = parts[parts.length - 1]
if (last && last.kind === "text") {
last.value += value
return
}
parts.push({ kind: "text", value })
}
function addTextSegment(segments: PromptSegment[], start: number, value: string) {
if (!value) return
segments.push({ kind: "text", value, start, end: start + value.length })
}
export function createAttachmentDisplay(path: string, selection?: TextSelection) {
const base = getFilename(path)
if (!selection) return base
return `${base} (${selection.startLine}-${selection.endLine})`
}
export function registerCandidate(
map: Map<string, AttachmentCandidate>,
candidate: AttachmentCandidate,
tokens: (string | undefined)[],
) {
for (const token of tokens) {
if (!token) continue
const normalized = token.toLowerCase()
if (map.has(normalized)) continue
map.set(normalized, candidate)
}
}
export function parsePrompt(value: string, lookup: Map<string, AttachmentCandidate>) {
const segments: PromptSegment[] = []
if (!value) return { parts: [] as PromptContentPart[], segments }
const pushTextRange = (rangeStart: number, rangeEnd: number) => {
if (rangeEnd <= rangeStart) return
const text = value.slice(rangeStart, rangeEnd)
let cursor = 0
for (const match of text.matchAll(mentionPattern)) {
const localIndex = match.index ?? 0
if (localIndex > cursor) {
addTextSegment(segments, rangeStart + cursor, text.slice(cursor, localIndex))
}
const token = match[1]
const candidate = lookup.get(token.toLowerCase())
if (candidate) {
const start = rangeStart + localIndex
const end = start + match[0].length
segments.push({
kind: "attachment",
token,
display: candidate.display,
path: candidate.path,
selection: candidate.selection,
origin: candidate.origin,
start,
end,
})
} else {
addTextSegment(segments, rangeStart + localIndex, match[0])
}
cursor = localIndex + match[0].length
}
if (cursor < text.length) {
addTextSegment(segments, rangeStart + cursor, text.slice(cursor))
}
}
pushTextRange(0, value.length)
const parts: PromptContentPart[] = []
for (const segment of segments) {
if (segment.kind === "text") {
pushTextPart(parts, segment.value)
} else {
const { start, end, ...attachment } = segment
parts.push(attachment as PromptAttachmentPart)
}
}
return { parts, segments }
}
export function composeDisplaySegments(
segments: PromptSegment[],
inputValue: string,
interim: string,
): PromptDisplaySegment[] {
if (segments.length === 0 && !interim) return []
const display: PromptDisplaySegment[] = segments.map((segment) => {
if (segment.kind === "text") {
return { kind: "text", value: segment.value }
}
const { start, end, ...part } = segment
const placeholder = inputValue.slice(start, end)
return { kind: "attachment", part: part as PromptAttachmentPart, source: placeholder }
})
if (interim) {
const leadingSpace = !!(inputValue && !inputValue.endsWith(" ") && !interim.startsWith(" "))
display.push({ kind: "interim", value: interim, leadingSpace })
}
return display
}
@@ -0,0 +1,396 @@
import { createEffect, createMemo, createResource, type Accessor } from "solid-js"
import type { SetStoreFunction } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
import { createSpeechRecognition } from "@/utils/speech"
import {
createAttachmentDisplay,
mentionPattern,
mentionTriggerPattern,
type PromptAttachmentPart,
type PromptAttachmentSegment,
} from "./prompt-form-helpers"
import type { LocalFile, TextSelection } from "@/context/local"
export type MentionRange = {
start: number
end: number
}
export interface PromptFormState {
promptInput: string
isDragOver: boolean
mentionOpen: boolean
mentionQuery: string
mentionRange: MentionRange | undefined
mentionIndex: number
mentionAnchorOffset: { x: number; y: number }
inlineAliases: Map<string, PromptAttachmentPart>
}
interface MentionControllerOptions {
state: PromptFormState
setState: SetStoreFunction<PromptFormState>
attachmentSegments: Accessor<PromptAttachmentSegment[]>
getInputRef: () => HTMLTextAreaElement | undefined
getOverlayRef: () => HTMLDivElement | undefined
getMeasureRef: () => HTMLDivElement | undefined
searchFiles: (query: string) => Promise<string[]>
resolveFile: (path: string) => LocalFile | undefined
addContextFile: (path: string, selection?: TextSelection) => void
getActiveContext: () => { path: string; selection?: TextSelection } | undefined
}
interface MentionKeyDownOptions {
event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }
mentionItems: () => string[]
insertMention: (path: string) => void
}
interface ScrollSyncOptions {
state: PromptFormState
getInputRef: () => HTMLTextAreaElement | undefined
getOverlayRef: () => HTMLDivElement | undefined
interim: Accessor<string>
updateMentionPosition: (element: HTMLTextAreaElement, range?: MentionRange) => void
}
export function usePromptSpeech(updatePromptInput: (updater: (prev: string) => string) => void) {
return createSpeechRecognition({
onFinal: (text) => updatePromptInput((prev) => (prev && !prev.endsWith(" ") ? `${prev} ` : prev) + text),
})
}
export function useMentionController(options: MentionControllerOptions) {
const mentionSource = createMemo(() => (options.state.mentionOpen ? options.state.mentionQuery : undefined))
const [mentionResults, { mutate: mutateMentionResults }] = createResource(mentionSource, (query) => {
if (!options.state.mentionOpen) return []
return options.searchFiles(query ?? "")
})
const mentionItems = createMemo(() => mentionResults() ?? [])
createEffect(() => {
if (!options.state.mentionOpen) return
options.state.mentionQuery
options.setState("mentionIndex", 0)
})
createEffect(() => {
if (!options.state.mentionOpen) return
queueMicrotask(() => {
const input = options.getInputRef()
if (!input) return
if (document.activeElement === input) return
input.focus()
})
})
createEffect(() => {
const used = new Set<string>()
for (const match of options.state.promptInput.matchAll(mentionPattern)) {
const token = match[1]
if (token) used.add(token.toLowerCase())
}
options.setState("inlineAliases", (prev) => {
if (prev.size === 0) return prev
const next = new Map(prev)
let changed = false
for (const key of prev.keys()) {
if (!used.has(key.toLowerCase())) {
next.delete(key)
changed = true
}
}
return changed ? next : prev
})
})
createEffect(() => {
if (!options.state.mentionOpen) return
const items = mentionItems()
if (items.length === 0) {
options.setState("mentionIndex", 0)
return
}
if (options.state.mentionIndex < items.length) return
options.setState("mentionIndex", items.length - 1)
})
createEffect(() => {
if (!options.state.mentionOpen) return
const rangeValue = options.state.mentionRange
if (!rangeValue) return
options.state.promptInput
queueMicrotask(() => {
const input = options.getInputRef()
if (!input) return
updateMentionPosition(input, rangeValue)
})
})
function closeMention() {
if (options.state.mentionOpen) options.setState("mentionOpen", false)
options.setState("mentionQuery", "")
options.setState("mentionRange", undefined)
options.setState("mentionIndex", 0)
mutateMentionResults(() => undefined)
options.setState("mentionAnchorOffset", { x: 0, y: 0 })
}
function updateMentionPosition(element: HTMLTextAreaElement, rangeValue = options.state.mentionRange) {
const measure = options.getMeasureRef()
if (!measure) return
if (!rangeValue) return
measure.style.width = `${element.clientWidth}px`
const measurement = element.value.slice(0, rangeValue.end)
measure.textContent = measurement
const caretSpan = document.createElement("span")
caretSpan.textContent = "\u200b"
measure.append(caretSpan)
const caretRect = caretSpan.getBoundingClientRect()
const containerRect = measure.getBoundingClientRect()
measure.removeChild(caretSpan)
const left = caretRect.left - containerRect.left
const top = caretRect.top - containerRect.top - element.scrollTop
options.setState("mentionAnchorOffset", { x: left, y: top < 0 ? 0 : top })
}
function isValidMentionQuery(value: string) {
return /^[A-Za-z0-9_\-./]*$/.test(value)
}
function syncMentionFromCaret(element: HTMLTextAreaElement) {
if (!options.state.mentionOpen) return
const rangeValue = options.state.mentionRange
if (!rangeValue) {
closeMention()
return
}
const caret = element.selectionEnd ?? element.selectionStart ?? element.value.length
if (rangeValue.start < 0 || rangeValue.start >= element.value.length) {
closeMention()
return
}
if (element.value[rangeValue.start] !== "@") {
closeMention()
return
}
if (caret <= rangeValue.start) {
closeMention()
return
}
const mentionValue = element.value.slice(rangeValue.start + 1, caret)
if (!isValidMentionQuery(mentionValue)) {
closeMention()
return
}
options.setState("mentionRange", { start: rangeValue.start, end: caret })
options.setState("mentionQuery", mentionValue)
updateMentionPosition(element, { start: rangeValue.start, end: caret })
}
function tryOpenMentionFromCaret(element: HTMLTextAreaElement) {
const selectionStart = element.selectionStart ?? element.value.length
const selectionEnd = element.selectionEnd ?? selectionStart
if (selectionStart !== selectionEnd) return false
const caret = selectionEnd
if (options.attachmentSegments().some((segment) => caret >= segment.start && caret <= segment.end)) {
return false
}
const before = element.value.slice(0, caret)
const match = before.match(mentionTriggerPattern)
if (!match) return false
const token = match[2] ?? ""
const start = caret - token.length - 1
if (start < 0) return false
options.setState("mentionOpen", true)
options.setState("mentionRange", { start, end: caret })
options.setState("mentionQuery", token)
options.setState("mentionIndex", 0)
queueMicrotask(() => {
updateMentionPosition(element, { start, end: caret })
})
return true
}
function handlePromptInput(event: InputEvent & { currentTarget: HTMLTextAreaElement }) {
const element = event.currentTarget
options.setState("promptInput", element.value)
if (options.state.mentionOpen) {
syncMentionFromCaret(element)
if (options.state.mentionOpen) return
}
const isDeletion = event.inputType ? event.inputType.startsWith("delete") : false
if (!isDeletion && tryOpenMentionFromCaret(element)) return
closeMention()
}
function handleMentionKeyDown({ event, mentionItems: items, insertMention }: MentionKeyDownOptions) {
if (!options.state.mentionOpen) return false
const list = items()
if (event.key === "ArrowDown") {
event.preventDefault()
if (list.length === 0) return true
const next = options.state.mentionIndex + 1 >= list.length ? 0 : options.state.mentionIndex + 1
options.setState("mentionIndex", next)
return true
}
if (event.key === "ArrowUp") {
event.preventDefault()
if (list.length === 0) return true
const previous = options.state.mentionIndex - 1 < 0 ? list.length - 1 : options.state.mentionIndex - 1
options.setState("mentionIndex", previous)
return true
}
if (event.key === "Enter") {
event.preventDefault()
const targetItem = list[options.state.mentionIndex] ?? list[0]
if (targetItem) insertMention(targetItem)
return true
}
if (event.key === "Escape") {
event.preventDefault()
closeMention()
return true
}
return false
}
function generateMentionAlias(path: string) {
const existing = new Set<string>()
for (const key of options.state.inlineAliases.keys()) {
existing.add(key.toLowerCase())
}
for (const match of options.state.promptInput.matchAll(mentionPattern)) {
const token = match[1]
if (token) existing.add(token.toLowerCase())
}
const base = getFilename(path)
if (base) {
if (!existing.has(base.toLowerCase())) return base
}
const directory = getDirectory(path)
if (base && directory) {
const segments = directory.split("/").filter(Boolean)
for (let i = segments.length - 1; i >= 0; i -= 1) {
const candidate = `${segments.slice(i).join("/")}/${base}`
if (!existing.has(candidate.toLowerCase())) return candidate
}
}
if (!existing.has(path.toLowerCase())) return path
const fallback = base || path || "file"
let index = 2
let candidate = `${fallback}-${index}`
while (existing.has(candidate.toLowerCase())) {
index += 1
candidate = `${fallback}-${index}`
}
return candidate
}
function insertMention(path: string) {
const input = options.getInputRef()
if (!input) return
const rangeValue = options.state.mentionRange
if (!rangeValue) return
const node = options.resolveFile(path)
const alias = generateMentionAlias(path)
const mentionText = `@${alias}`
const value = options.state.promptInput
const before = value.slice(0, rangeValue.start)
const after = value.slice(rangeValue.end)
const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
const needsTrailingSpace = after.length > 0 && !/^\s/.test(after)
const leading = needsLeadingSpace ? `${before} ` : before
const trailingSpacer = needsTrailingSpace ? " " : ""
const nextValue = `${leading}${mentionText}${trailingSpacer}${after}`
const origin = options.getActiveContext()?.path === path ? "active" : "context"
const part: PromptAttachmentPart = {
kind: "attachment",
token: alias,
display: createAttachmentDisplay(path, node?.selection),
path,
selection: node?.selection,
origin,
}
options.setState("promptInput", nextValue)
if (input.value !== nextValue) {
input.value = nextValue
}
options.setState("inlineAliases", (prev) => {
const next = new Map(prev)
next.set(alias, part)
return next
})
options.addContextFile(path, node?.selection)
closeMention()
queueMicrotask(() => {
const caret = leading.length + mentionText.length + trailingSpacer.length
input.setSelectionRange(caret, caret)
syncMentionFromCaret(input)
})
}
return {
mentionResults,
mentionItems,
closeMention,
syncMentionFromCaret,
tryOpenMentionFromCaret,
updateMentionPosition,
handlePromptInput,
handleMentionKeyDown,
insertMention,
}
}
export function usePromptScrollSync(options: ScrollSyncOptions) {
let shouldAutoScroll = true
createEffect(() => {
options.state.promptInput
options.interim()
queueMicrotask(() => {
const input = options.getInputRef()
const overlay = options.getOverlayRef()
if (!input || !overlay) return
if (!shouldAutoScroll) {
overlay.scrollTop = input.scrollTop
if (options.state.mentionOpen) options.updateMentionPosition(input)
return
}
const maxInputScroll = input.scrollHeight - input.clientHeight
const next = maxInputScroll > 0 ? maxInputScroll : 0
input.scrollTop = next
overlay.scrollTop = next
if (options.state.mentionOpen) options.updateMentionPosition(input)
})
})
function handlePromptScroll(event: Event & { currentTarget: HTMLTextAreaElement }) {
const target = event.currentTarget
shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4
const overlay = options.getOverlayRef()
if (overlay) overlay.scrollTop = target.scrollTop
if (options.state.mentionOpen) options.updateMentionPosition(target)
}
function resetScrollPosition() {
shouldAutoScroll = true
const input = options.getInputRef()
const overlay = options.getOverlayRef()
if (input) input.scrollTop = 0
if (overlay) overlay.scrollTop = 0
}
return {
handlePromptScroll,
resetScrollPosition,
setAutoScroll: (value: boolean) => {
shouldAutoScroll = value
},
}
}
+360 -74
View File
@@ -1,15 +1,25 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { Button, FileIcon, Icon, IconButton, Tooltip } from "@/ui"
import { Select } from "@/components/select"
import { For, Show, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Popover } from "@kobalte/core/popover"
import { Tooltip, Button, Icon, Select } from "@opencode-ai/ui"
import { FileIcon, IconButton } from "@/ui"
import { useLocal } from "@/context"
import type { FileContext, LocalFile } from "@/context/local"
import { getFilename } from "@/utils"
import { createSpeechRecognition } from "@/utils/speech"
import { getDirectory, getFilename } from "@/utils"
import { composeDisplaySegments, createAttachmentDisplay, parsePrompt, registerCandidate } from "./prompt-form-helpers"
import type {
AttachmentCandidate,
PromptAttachmentPart,
PromptAttachmentSegment,
PromptDisplaySegment,
PromptSubmitValue,
} from "./prompt-form-helpers"
import { useMentionController, usePromptScrollSync, usePromptSpeech, type PromptFormState } from "./prompt-form-hooks"
interface PromptFormProps {
class?: string
classList?: Record<string, boolean>
onSubmit: (prompt: string) => Promise<void> | void
onSubmit: (prompt: PromptSubmitValue) => Promise<void> | void
onOpenModelSelect: () => void
onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void
}
@@ -17,8 +27,16 @@ interface PromptFormProps {
export default function PromptForm(props: PromptFormProps) {
const local = useLocal()
const [prompt, setPrompt] = createSignal("")
const [isDragOver, setIsDragOver] = createSignal(false)
const [state, setState] = createStore<PromptFormState>({
promptInput: "",
isDragOver: false,
mentionOpen: false,
mentionQuery: "",
mentionRange: undefined,
mentionIndex: 0,
mentionAnchorOffset: { x: 0, y: 0 },
inlineAliases: new Map<string, PromptAttachmentPart>(),
})
const placeholderText = "Start typing or speaking..."
@@ -28,79 +46,212 @@ export default function PromptForm(props: PromptFormProps) {
interim: interimTranscript,
start: startSpeech,
stop: stopSpeech,
} = createSpeechRecognition({
onFinal: (text) => setPrompt((prev) => (prev && !prev.endsWith(" ") ? prev + " " : prev) + text),
})
} = usePromptSpeech((updater) => setState("promptInput", updater))
let inputRef: HTMLTextAreaElement | undefined = undefined
let overlayContainerRef: HTMLDivElement | undefined = undefined
let shouldAutoScroll = true
let mentionMeasureRef: HTMLDivElement | undefined = undefined
const promptContent = createMemo(() => {
const base = prompt() || ""
const attachmentLookup = createMemo(() => {
const map = new Map<string, AttachmentCandidate>()
const activeFile = local.context.active()
if (activeFile) {
registerCandidate(
map,
{
origin: "active",
path: activeFile.path,
selection: activeFile.selection,
display: createAttachmentDisplay(activeFile.path, activeFile.selection),
},
[activeFile.path, getFilename(activeFile.path)],
)
}
for (const item of local.context.all()) {
registerCandidate(
map,
{
origin: "context",
path: item.path,
selection: item.selection,
display: createAttachmentDisplay(item.path, item.selection),
},
[item.path, getFilename(item.path)],
)
}
for (const [alias, part] of state.inlineAliases) {
registerCandidate(
map,
{
origin: part.origin,
path: part.path,
selection: part.selection,
display: part.display ?? createAttachmentDisplay(part.path, part.selection),
},
[alias],
)
}
return map
})
const parsedPrompt = createMemo(() => parsePrompt(state.promptInput, attachmentLookup()))
const baseParts = createMemo(() => parsedPrompt().parts)
const attachmentSegments = createMemo<PromptAttachmentSegment[]>(() =>
parsedPrompt().segments.filter((segment): segment is PromptAttachmentSegment => segment.kind === "attachment"),
)
const {
mentionResults,
mentionItems,
closeMention,
syncMentionFromCaret,
updateMentionPosition,
handlePromptInput,
handleMentionKeyDown,
insertMention,
} = useMentionController({
state,
setState,
attachmentSegments,
getInputRef: () => inputRef,
getOverlayRef: () => overlayContainerRef,
getMeasureRef: () => mentionMeasureRef,
searchFiles: (query) => local.file.search(query),
resolveFile: (path) => local.file.node(path) ?? undefined,
addContextFile: (path, selection) =>
local.context.add({
type: "file",
path,
selection,
}),
getActiveContext: () => local.context.active() ?? undefined,
})
const { handlePromptScroll, resetScrollPosition } = usePromptScrollSync({
state,
getInputRef: () => inputRef,
getOverlayRef: () => overlayContainerRef,
interim: () => (isRecording() ? interimTranscript() : ""),
updateMentionPosition,
})
const displaySegments = createMemo<PromptDisplaySegment[]>(() => {
const value = state.promptInput
const segments = parsedPrompt().segments
const interim = isRecording() ? interimTranscript() : ""
if (!base && !interim) {
return <span class="text-text-muted/70">{placeholderText}</span>
}
const needsSpace = base && interim && !base.endsWith(" ") && !interim.startsWith(" ")
return (
<>
<span class="text-text">{base}</span>
{interim && (
<span class="text-text-muted/60 italic">
{needsSpace ? " " : ""}
{interim}
</span>
)}
</>
)
return composeDisplaySegments(segments, value, interim)
})
createEffect(() => {
prompt()
interimTranscript()
queueMicrotask(() => {
if (!inputRef) return
if (!overlayContainerRef) return
if (!shouldAutoScroll) {
overlayContainerRef.scrollTop = inputRef.scrollTop
return
const hasDisplaySegments = createMemo(() => displaySegments().length > 0)
function handleAttachmentNavigation(
event: KeyboardEvent & { currentTarget: HTMLTextAreaElement },
direction: "left" | "right",
) {
const element = event.currentTarget
const caret = element.selectionStart ?? 0
const segments = attachmentSegments()
if (direction === "left") {
let match = segments.find((segment) => caret > segment.start && caret <= segment.end)
if (!match && element.selectionStart !== element.selectionEnd) {
match = segments.find(
(segment) => element.selectionStart === segment.start && element.selectionEnd === segment.end,
)
}
scrollPromptToEnd()
})
})
const handlePromptKeyDown = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => {
if (event.isComposing) return
if (event.key === "Enter" && !event.shiftKey) {
if (!match) return false
event.preventDefault()
inputRef?.form?.requestSubmit()
if (element.selectionStart === match.start && element.selectionEnd === match.end) {
const next = Math.max(0, match.start)
element.setSelectionRange(next, next)
syncMentionFromCaret(element)
return true
}
element.setSelectionRange(match.start, match.end)
syncMentionFromCaret(element)
return true
}
if (direction === "right") {
let match = segments.find((segment) => caret >= segment.start && caret < segment.end)
if (!match && element.selectionStart !== element.selectionEnd) {
match = segments.find(
(segment) => element.selectionStart === segment.start && element.selectionEnd === segment.end,
)
}
if (!match) return false
event.preventDefault()
if (element.selectionStart === match.start && element.selectionEnd === match.end) {
const next = match.end
element.setSelectionRange(next, next)
syncMentionFromCaret(element)
return true
}
element.setSelectionRange(match.start, match.end)
syncMentionFromCaret(element)
return true
}
return false
}
const handlePromptScroll = (event: Event & { currentTarget: HTMLTextAreaElement }) => {
function renderAttachmentChip(part: PromptAttachmentPart, _placeholder: string) {
const display = part.display ?? createAttachmentDisplay(part.path, part.selection)
return <span class="truncate max-w-[16ch] text-primary">@{display}</span>
}
function renderTextSegment(value: string) {
if (!value) return undefined
return <span class="text-text">{value}</span>
}
function handlePromptKeyDown(event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) {
if (event.isComposing) return
const target = event.currentTarget
shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4
if (overlayContainerRef) overlayContainerRef.scrollTop = target.scrollTop
}
const key = event.key
const scrollPromptToEnd = () => {
if (!inputRef) return
const maxInputScroll = inputRef.scrollHeight - inputRef.clientHeight
const next = maxInputScroll > 0 ? maxInputScroll : 0
inputRef.scrollTop = next
if (overlayContainerRef) overlayContainerRef.scrollTop = next
shouldAutoScroll = true
const handled = handleMentionKeyDown({
event,
mentionItems,
insertMention,
})
if (handled) return
if (!state.mentionOpen) {
if (key === "ArrowLeft") {
if (handleAttachmentNavigation(event, "left")) return
}
if (key === "ArrowRight") {
if (handleAttachmentNavigation(event, "right")) return
}
}
if (key === "ArrowLeft" || key === "ArrowRight" || key === "Home" || key === "End") {
queueMicrotask(() => {
syncMentionFromCaret(target)
})
}
if (key === "Enter" && !event.shiftKey) {
event.preventDefault()
target.form?.requestSubmit()
}
}
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault()
const currentPrompt = prompt()
setPrompt("")
shouldAutoScroll = true
if (overlayContainerRef) overlayContainerRef.scrollTop = 0
const parts = baseParts()
const text = parts
.map((part) => {
if (part.kind === "text") return part.value
return `@${part.path}`
})
.join("")
const currentPrompt: PromptSubmitValue = {
text,
parts,
}
setState("promptInput", "")
resetScrollPosition()
if (inputRef) {
inputRef.scrollTop = 0
inputRef.blur()
}
@@ -114,26 +265,25 @@ export default function PromptForm(props: PromptFormProps) {
return (
<form onSubmit={handleSubmit} class={props.class} classList={props.classList}>
<div
class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
flex flex-col gap-1
bg-gradient-to-b from-background-panel/90 to-background/90
class="w-full min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
flex flex-col gap-1 bg-gradient-to-b from-background-panel/90 to-background/90
ring-1 ring-border-active/50 border border-transparent
focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary
transition-all duration-200"
classList={{
"shadow-[0_0_33px_rgba(0,0,0,0.8)]": !!local.file.active(),
"ring-2 ring-primary/60 bg-primary/5": isDragOver(),
"ring-2 ring-primary/60 bg-primary/5": state.isDragOver,
}}
onDragEnter={(event) => {
const evt = event as unknown as globalThis.DragEvent
if (evt.dataTransfer?.types.includes("text/plain")) {
evt.preventDefault()
setIsDragOver(true)
setState("isDragOver", true)
}
}}
onDragLeave={(event) => {
if (event.currentTarget === event.target) {
setIsDragOver(false)
setState("isDragOver", false)
}
}}
onDragOver={(event) => {
@@ -146,7 +296,7 @@ export default function PromptForm(props: PromptFormProps) {
onDrop={(event) => {
const evt = event as unknown as globalThis.DragEvent
evt.preventDefault()
setIsDragOver(false)
setState("isDragOver", false)
const data = evt.dataTransfer?.getData("text/plain")
if (data && data.startsWith("file:")) {
@@ -177,9 +327,24 @@ export default function PromptForm(props: PromptFormProps) {
inputRef = element ?? undefined
props.onInputRefChange?.(inputRef)
}}
value={prompt()}
onInput={(event) => setPrompt(event.currentTarget.value)}
value={state.promptInput}
onInput={handlePromptInput}
onKeyDown={handlePromptKeyDown}
onClick={(event) =>
queueMicrotask(() => {
syncMentionFromCaret(event.currentTarget)
})
}
onSelect={(event) =>
queueMicrotask(() => {
syncMentionFromCaret(event.currentTarget)
})
}
onBlur={(event) => {
const next = event.relatedTarget as HTMLElement | null
if (next && next.closest('[data-mention-popover="true"]')) return
closeMention()
}}
onScroll={handlePromptScroll}
placeholder={placeholderText}
autocapitalize="off"
@@ -196,10 +361,30 @@ export default function PromptForm(props: PromptFormProps) {
}}
class="pointer-events-none absolute inset-0 overflow-hidden"
>
<div class="px-0.5 text-base font-light leading-relaxed whitespace-pre-wrap text-left text-text">
{promptContent()}
</div>
<PromptDisplayOverlay
hasDisplaySegments={hasDisplaySegments()}
displaySegments={displaySegments()}
placeholder={placeholderText}
renderAttachmentChip={renderAttachmentChip}
renderTextSegment={renderTextSegment}
/>
</div>
<div
ref={(element) => {
mentionMeasureRef = element ?? undefined
}}
class="pointer-events-none invisible absolute inset-0 whitespace-pre-wrap text-base font-light leading-relaxed px-0.5"
aria-hidden="true"
></div>
<MentionSuggestions
open={state.mentionOpen}
anchor={state.mentionAnchorOffset}
loading={mentionResults.loading}
items={mentionItems()}
activeIndex={state.mentionIndex}
onHover={(index) => setState("mentionIndex", index)}
onSelect={insertMention}
/>
</div>
<div class="flex justify-between items-center text-xs text-text-muted">
<div class="flex gap-2 items-center">
@@ -293,3 +478,104 @@ const FileTag = (props: { file: FileContext; onClose: () => void }) => (
</div>
</div>
)
function PromptDisplayOverlay(props: {
hasDisplaySegments: boolean
displaySegments: PromptDisplaySegment[]
placeholder: string
renderAttachmentChip: (part: PromptAttachmentPart, placeholder: string) => JSX.Element
renderTextSegment: (value: string) => JSX.Element | undefined
}) {
return (
<div class="px-0.5 text-base font-light leading-relaxed whitespace-pre-wrap text-left">
<Show when={props.hasDisplaySegments} fallback={<span class="text-text-muted/70">{props.placeholder}</span>}>
<For each={props.displaySegments}>
{(segment) => {
if (segment.kind === "text") {
return props.renderTextSegment(segment.value)
}
if (segment.kind === "attachment") {
return props.renderAttachmentChip(segment.part, segment.source)
}
return (
<span class="text-text-muted/60 italic">
{segment.leadingSpace ? ` ${segment.value}` : segment.value}
</span>
)
}}
</For>
</Show>
</div>
)
}
function MentionSuggestions(props: {
open: boolean
anchor: { x: number; y: number }
loading: boolean
items: string[]
activeIndex: number
onHover: (index: number) => void
onSelect: (path: string) => void
}) {
return (
<Popover open={props.open} modal={false} gutter={8} placement="bottom-start">
<Popover.Trigger class="hidden" />
<Popover.Anchor
class="pointer-events-none absolute top-0 left-0 w-0 h-0"
style={{ transform: `translate(${props.anchor.x}px, ${props.anchor.y}px)` }}
/>
<Popover.Portal>
<Popover.Content
data-mention-popover="true"
class="z-50 w-72 max-h-60 overflow-y-auto rounded-md border border-border-subtle/40 bg-background-panel shadow-[0_10px_30px_rgba(0,0,0,0.35)] focus:outline-none"
>
<div class="py-1">
<Show when={props.loading}>
<div class="flex items-center gap-2 px-3 py-2 text-xs text-text-muted">
<Icon name="refresh" size={12} class="animate-spin" />
<span>Searching</span>
</div>
</Show>
<Show when={!props.loading && props.items.length === 0}>
<div class="px-3 py-2 text-xs text-text-muted/80">No matching files</div>
</Show>
<For each={props.items}>
{(path, indexAccessor) => {
const index = indexAccessor()
const dir = getDirectory(path)
return (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => props.onHover(index)}
onClick={() => props.onSelect(path)}
class="w-full px-3 py-2 flex items-center gap-2 rounded-md text-left text-xs transition-colors"
classList={{
"bg-background-element text-text": index === props.activeIndex,
"text-text-muted": index !== props.activeIndex,
}}
>
<FileIcon node={{ path, type: "file" }} class="size-3 shrink-0" />
<div class="flex flex-col min-w-0">
<span class="truncate">{getFilename(path)}</span>
{dir && <span class="truncate text-text-muted/70">{dir}</span>}
</div>
</button>
)
}}
</For>
</div>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
export type {
PromptAttachmentPart,
PromptAttachmentSegment,
PromptContentPart,
PromptDisplaySegment,
PromptSubmitValue,
} from "./prompt-form-helpers"
@@ -0,0 +1,264 @@
import { createEffect, on, Component, createMemo, Show } from "solid-js"
import { createStore } from "solid-js/store"
interface TextPart {
type: "text"
content: string
}
interface AttachmentPart {
type: "attachment"
fileId: string
name: string
}
export type ContentPart = TextPart | AttachmentPart
export interface AttachmentToAdd {
id: string
name: string
}
type AddAttachmentCallback = (attachment: AttachmentToAdd) => void
export interface PopoverState {
isOpen: boolean
searchQuery: string
addAttachment: AddAttachmentCallback
}
interface PromptInputProps {
onSubmit: (parts: ContentPart[]) => void
onShowAttachments?: (state: PopoverState | null) => void
class?: string
}
export const PromptInput: Component<PromptInputProps> = (props) => {
let editorRef: HTMLDivElement | undefined
const defaultParts = [{ type: "text", content: "" } as const]
const [store, setStore] = createStore<{
contentParts: ContentPart[]
popover: {
isOpen: boolean
searchQuery: string
}
}>({
contentParts: defaultParts,
popover: {
isOpen: false,
searchQuery: "",
},
})
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
createEffect(
on(
() => store.contentParts,
(currentParts) => {
if (!editorRef) return
const domParts = parseFromDOM()
if (isEqual(currentParts, domParts)) return
const selection = window.getSelection()
let cursorPosition: number | null = null
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
cursorPosition = getCursorPosition(editorRef)
}
editorRef.innerHTML = ""
currentParts.forEach((part) => {
if (part.type === "text") {
editorRef!.appendChild(document.createTextNode(part.content))
} else if (part.type === "attachment") {
const pill = document.createElement("span")
pill.textContent = `@${part.name}`
pill.className = "attachment-pill"
pill.setAttribute("data-file-id", part.fileId)
pill.setAttribute("contenteditable", "false")
editorRef!.appendChild(pill)
}
})
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
}
},
),
)
createEffect(() => {
if (store.popover.isOpen) {
props.onShowAttachments?.({
isOpen: true,
searchQuery: store.popover.searchQuery,
addAttachment: addAttachment,
})
} else {
props.onShowAttachments?.(null)
}
})
const parseFromDOM = (): ContentPart[] => {
if (!editorRef) return []
const newParts: ContentPart[] = []
editorRef.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent) newParts.push({ type: "text", content: node.textContent })
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.fileId) {
newParts.push({
type: "attachment",
fileId: (node as HTMLElement).dataset.fileId!,
name: node.textContent!.substring(1),
})
}
})
if (newParts.length === 0) newParts.push(...defaultParts)
return newParts
}
const handleInput = () => {
const rawParts = parseFromDOM()
const cursorPosition = getCursorPosition(editorRef!)
const rawText = rawParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("")
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
if (atMatch) {
setStore("popover", { isOpen: true, searchQuery: atMatch[1] })
} else if (store.popover.isOpen) {
setStore("popover", "isOpen", false)
}
setStore("contentParts", rawParts)
}
const addAttachment: AddAttachmentCallback = (attachment) => {
const rawText = store.contentParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("")
const cursorPosition = getCursorPosition(editorRef!)
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
if (!atMatch) return
const startIndex = atMatch.index!
// Create new structured content
const newParts: ContentPart[] = []
const textBeforeTrigger = rawText.substring(0, startIndex)
if (textBeforeTrigger) newParts.push({ type: "text", content: textBeforeTrigger })
newParts.push({ type: "attachment", fileId: attachment.id, name: attachment.name })
// Add a space after the pill for better UX
newParts.push({ type: "text", content: " " })
const textAfterCursor = rawText.substring(cursorPosition)
if (textAfterCursor) newParts.push({ type: "text", content: textAfterCursor })
setStore("contentParts", newParts)
setStore("popover", "isOpen", false)
// Set cursor position after the newly added pill + space
// We need to wait for the DOM to update
queueMicrotask(() => {
setCursorPosition(editorRef!, textBeforeTrigger.length + 1 + attachment.name.length + 1)
})
}
const handleKeyDown = (event: KeyboardEvent) => {
if (store.popover.isOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
// In a real implementation, you'd prevent default and delegate this to the popover
console.log("Key press delegated to popover:", event.key)
event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (store.contentParts.length > 0) {
props.onSubmit([...store.contentParts])
setStore("contentParts", defaultParts)
}
}
}
return (
<div
classList={{
"size-full max-w-xl bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
[props.class ?? ""]: !!props.class,
}}
>
<div class="p-3" />
<div class="relative">
<div
ref={editorRef}
contenteditable="true"
onInput={handleInput}
onKeyDown={handleKeyDown}
classList={{
"w-full p-3 text-sm focus:outline-none": true,
}}
/>
<Show when={isEmpty()}>
<div class="absolute bottom-0 left-0 p-3 text-sm text-text-weak pointer-events-none">
Plan and build anything
</div>
</Show>
</div>
<div class="p-3" />
</div>
)
}
function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean {
if (arrA.length !== arrB.length) return false
for (let i = 0; i < arrA.length; i++) {
const partA = arrA[i]
const partB = arrB[i]
if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
return false
}
if (partA.type === "attachment" && partA.fileId !== (partB as AttachmentPart).fileId) {
return false
}
}
return true
}
function getCursorPosition(parent: HTMLElement): number {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0)
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(parent)
preCaretRange.setEnd(range.startContainer, range.startOffset)
return preCaretRange.toString().length
}
function setCursorPosition(parent: HTMLElement, position: number) {
let child = parent.firstChild
let offset = position
while (child) {
if (offset > child.textContent!.length) {
offset -= child.textContent!.length
child = child.nextSibling
} else {
try {
const range = document.createRange()
const sel = window.getSelection()
range.setStart(child, offset)
range.collapse(true)
sel?.removeAllRanges()
sel?.addRange(range)
} catch (e) {
console.error("Failed to set cursor position.", e)
}
return
}
}
}
@@ -1,217 +0,0 @@
import { batch, createContext, createMemo, createSignal, onCleanup, Show, useContext } from "solid-js"
import type { ComponentProps, JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context"
type PaneDefault = number | { size: number; visible?: boolean }
type LayoutContextValue = {
id: string
register: (pane: string, options: { min?: number | string; max?: number | string }) => void
size: (pane: string) => number
visible: (pane: string) => boolean
percent: (pane: string) => number
next: (pane: string) => string | undefined
startDrag: (left: string, right: string | undefined, event: MouseEvent) => void
dragging: () => string | undefined
}
const LayoutContext = createContext<LayoutContextValue | undefined>(undefined)
export interface ResizeableLayoutProps {
id: string
defaults: Record<string, PaneDefault>
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
children: JSX.Element
}
export interface ResizeablePaneProps {
id: string
minSize?: number | string
maxSize?: number | string
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
children: JSX.Element
}
export function ResizeableLayout(props: ResizeableLayoutProps) {
const local = useLocal()
const [meta, setMeta] = createStore<Record<string, { min: number; max: number; minPx?: number; maxPx?: number }>>({})
const [dragging, setDragging] = createSignal<string>()
let container: HTMLDivElement | undefined
local.layout.ensure(props.id, props.defaults)
const order = createMemo(() => local.layout.order(props.id))
const visibleOrder = createMemo(() => order().filter((pane) => local.layout.visible(props.id, pane)))
const totalVisible = createMemo(() => {
const panes = visibleOrder()
if (!panes.length) return 0
return panes.reduce((total, pane) => total + local.layout.size(props.id, pane), 0)
})
const percent = (pane: string) => {
const panes = visibleOrder()
if (!panes.length) return 0
const total = totalVisible()
if (!total) return 100 / panes.length
return (local.layout.size(props.id, pane) / total) * 100
}
const nextPane = (pane: string) => {
const panes = visibleOrder()
const index = panes.indexOf(pane)
if (index === -1) return undefined
return panes[index + 1]
}
const minMax = (pane: string) => meta[pane] ?? { min: 5, max: 95 }
const pxToPercent = (px: number, total: number) => (px / total) * 100
const boundsForPair = (left: string, right: string, total: number) => {
const leftMeta = minMax(left)
const rightMeta = minMax(right)
const containerWidth = container?.getBoundingClientRect().width ?? 0
let minLeft = leftMeta.min
let maxLeft = leftMeta.max
let minRight = rightMeta.min
let maxRight = rightMeta.max
if (containerWidth && leftMeta.minPx !== undefined) minLeft = pxToPercent(leftMeta.minPx, containerWidth)
if (containerWidth && leftMeta.maxPx !== undefined) maxLeft = pxToPercent(leftMeta.maxPx, containerWidth)
if (containerWidth && rightMeta.minPx !== undefined) minRight = pxToPercent(rightMeta.minPx, containerWidth)
if (containerWidth && rightMeta.maxPx !== undefined) maxRight = pxToPercent(rightMeta.maxPx, containerWidth)
const finalMinLeft = Math.max(minLeft, total - maxRight)
const finalMaxLeft = Math.min(maxLeft, total - minRight)
return {
min: Math.min(finalMinLeft, finalMaxLeft),
max: Math.max(finalMinLeft, finalMaxLeft),
}
}
const setPair = (left: string, right: string, leftSize: number, rightSize: number) => {
batch(() => {
local.layout.setSize(props.id, left, leftSize)
local.layout.setSize(props.id, right, rightSize)
})
}
const startDrag = (left: string, right: string | undefined, event: MouseEvent) => {
if (!right) return
if (!container) return
const rect = container.getBoundingClientRect()
if (!rect.width) return
event.preventDefault()
const startX = event.clientX
const startLeft = local.layout.size(props.id, left)
const startRight = local.layout.size(props.id, right)
const total = startLeft + startRight
const bounds = boundsForPair(left, right, total)
const move = (moveEvent: MouseEvent) => {
const delta = ((moveEvent.clientX - startX) / rect.width) * 100
const nextLeft = Math.max(bounds.min, Math.min(bounds.max, startLeft + delta))
const nextRight = total - nextLeft
setPair(left, right, nextLeft, nextRight)
}
const stop = () => {
setDragging()
document.removeEventListener("mousemove", move)
document.removeEventListener("mouseup", stop)
}
setDragging(left)
document.addEventListener("mousemove", move)
document.addEventListener("mouseup", stop)
onCleanup(() => stop())
}
const register = (pane: string, options: { min?: number | string; max?: number | string }) => {
let min = 5
let max = 95
let minPx: number | undefined
let maxPx: number | undefined
if (typeof options.min === "string" && options.min.endsWith("px")) {
minPx = parseInt(options.min)
min = 0
} else if (typeof options.min === "number") {
min = options.min
}
if (typeof options.max === "string" && options.max.endsWith("px")) {
maxPx = parseInt(options.max)
max = 100
} else if (typeof options.max === "number") {
max = options.max
}
setMeta(pane, () => ({ min, max, minPx, maxPx }))
const fallback = props.defaults[pane]
local.layout.ensurePane(props.id, pane, fallback ?? { size: min, visible: true })
}
const contextValue: LayoutContextValue = {
id: props.id,
register,
size: (pane) => local.layout.size(props.id, pane),
visible: (pane) => local.layout.visible(props.id, pane),
percent,
next: nextPane,
startDrag,
dragging,
}
return (
<LayoutContext.Provider value={contextValue}>
<div
ref={(node) => {
container = node ?? undefined
}}
class={props.class ? `relative flex h-full w-full ${props.class}` : "relative flex h-full w-full"}
classList={props.classList}
>
{props.children}
</div>
</LayoutContext.Provider>
)
}
export function ResizeablePane(props: ResizeablePaneProps) {
const context = useContext(LayoutContext)!
context.register(props.id, { min: props.minSize, max: props.maxSize })
const visible = () => context.visible(props.id)
const width = () => context.percent(props.id)
const next = () => context.next(props.id)
const dragging = () => context.dragging() === props.id
return (
<Show when={visible()}>
<div
class={props.class ? `relative flex h-full flex-col ${props.class}` : "relative flex h-full flex-col"}
classList={props.classList}
style={{
width: `${width()}%`,
flex: `0 0 ${width()}%`,
}}
>
{props.children}
<Show when={next()}>
<div
class="absolute top-0 -right-1 h-full w-1.5 cursor-col-resize z-50 group"
onMouseDown={(event) => context.startDrag(props.id, next(), event)}
>
<div
classList={{
"w-0.5 h-full bg-transparent transition-colors group-hover:bg-border-active": true,
"bg-border-active!": dragging(),
}}
/>
</div>
</Show>
</div>
</Show>
)
}
@@ -1,6 +1,7 @@
import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Icon, IconButton } from "@/ui"
import { Icon } from "@opencode-ai/ui"
import { IconButton } from "@/ui"
import { createStore } from "solid-js/store"
import { entries, flatMap, groupBy, map, pipe } from "remeda"
import { createList } from "solid-list"
-107
View File
@@ -1,107 +0,0 @@
import { Select as KobalteSelect } from "@kobalte/core/select"
import { createMemo } from "solid-js"
import type { ComponentProps } from "solid-js"
import { Icon } from "@/ui/icon"
import { pipe, groupBy, entries, map } from "remeda"
import { Button, type ButtonProps } from "@/ui"
export interface SelectProps<T> {
placeholder?: string
options: T[]
current?: T
value?: (x: T) => string
label?: (x: T) => string
groupBy?: (x: T) => string
onSelect?: (value: T | undefined) => void
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
}
export function Select<T>(props: SelectProps<T> & ButtonProps) {
const grouped = createMemo(() => {
const result = pipe(
props.options,
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(),
map(([k, v]) => ({ category: k, options: v })),
)
return result
})
return (
<KobalteSelect<T, { category: string; options: T[] }>
value={props.current}
options={grouped()}
optionValue={(x) => (props.value ? props.value(x) : (x as string))}
optionTextValue={(x) => (props.label ? props.label(x) : (x as string))}
optionGroupChildren="options"
placeholder={props.placeholder}
sectionComponent={(props) => (
<KobalteSelect.Section class="text-xs uppercase text-text-muted/60 font-light mt-3 first:mt-0 ml-2">
{props.section.rawValue.category}
</KobalteSelect.Section>
)}
itemComponent={(itemProps) => (
<KobalteSelect.Item
classList={{
"relative flex cursor-pointer select-none items-center": true,
"rounded-sm px-2 py-0.5 text-xs outline-none text-text": true,
"transition-colors data-[disabled]:pointer-events-none": true,
"data-[highlighted]:bg-background-element data-[disabled]:opacity-50": true,
[props.class ?? ""]: !!props.class,
}}
{...itemProps}
>
<KobalteSelect.ItemLabel>
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
</KobalteSelect.ItemLabel>
<KobalteSelect.ItemIndicator class="ml-auto">
<Icon name="checkmark" size={16} />
</KobalteSelect.ItemIndicator>
</KobalteSelect.Item>
)}
onChange={(v) => {
props.onSelect?.(v ?? undefined)
}}
>
<KobalteSelect.Trigger
as={Button}
size={props.size || "sm"}
variant={props.variant || "secondary"}
classList={{
...(props.classList ?? {}),
[props.class ?? ""]: !!props.class,
}}
>
<KobalteSelect.Value<T>>
{(state) => {
const selected = state.selectedOption() ?? props.current
if (!selected) return props.placeholder || ""
if (props.label) return props.label(selected)
return selected as string
}}
</KobalteSelect.Value>
<KobalteSelect.Icon
classList={{
"size-fit shrink-0 text-text-muted transition-transform duration-100 data-[expanded]:rotate-180": true,
}}
>
<Icon name="chevron-down" size={24} />
</KobalteSelect.Icon>
</KobalteSelect.Trigger>
<KobalteSelect.Portal>
<KobalteSelect.Content
classList={{
"min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true,
"bg-background-panel p-1 shadow-md z-50": true,
"data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95": true,
"data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
}}
>
<KobalteSelect.Listbox class="overflow-y-auto max-h-48" />
</KobalteSelect.Content>
</KobalteSelect.Portal>
</KobalteSelect>
)
}
@@ -1,28 +0,0 @@
import { useSync, useLocal } from "@/context"
import { Button, Tooltip } from "@/ui"
import { VList } from "virtua/solid"
export default function SessionList() {
const sync = useSync()
const local = useLocal()
return (
<VList data={sync.data.session} class="p-2">
{(session) => (
<Tooltip placement="right" value={session.title} class="w-full min-w-0">
<Button
size="sm"
variant="ghost"
classList={{
"w-full min-w-0 py-1 text-left truncate justify-start text-text-muted text-xs": true,
"text-text!": local.session.active()?.id === session.id,
}}
onClick={() => local.session.setActive(session.id)}
>
<span class="truncate">{session.title}</span>
</Button>
</Tooltip>
)}
</VList>
)
}
@@ -1,6 +1,7 @@
import { useLocal, useSync } from "@/context"
import { Collapsible, Icon } from "@/ui"
import type { Part, ToolPart } from "@opencode-ai/sdk"
import { Icon, Tooltip } from "@opencode-ai/ui"
import { Collapsible } from "@/ui"
import type { AssistantMessage, Part, ToolPart } from "@opencode-ai/sdk"
import { DateTime } from "luxon"
import {
createSignal,
@@ -20,6 +21,8 @@ import { Markdown } from "./markdown"
import { Code } from "./code"
import { createElementSize } from "@solid-primitives/resize-observer"
import { createScrollPosition } from "@solid-primitives/scroll"
import { ProgressCircle } from "./progress-circle"
import { pipe, sumBy } from "remeda"
function Part(props: ParentProps & ComponentProps<"div">) {
const [local, others] = splitProps(props, ["class", "classList", "children"])
@@ -32,7 +35,7 @@ function Part(props: ParentProps & ComponentProps<"div">) {
}}
{...others}
>
<p class="text-xs leading-4 text-left text-text-muted/60 font-medium">{local.children}</p>
<p class="text-12-medium text-left">{local.children}</p>
</div>
)
}
@@ -44,8 +47,8 @@ function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps
<Part>{props.title}</Part>
</Collapsible.Trigger>
<Collapsible.Content>
<p class="flex-auto py-1 text-xs min-w-0 text-pretty">
<span class="text-text-muted/60 break-words">{props.children}</span>
<p class="flex-auto min-w-0 text-pretty">
<span class="text-12-medium text-text-weak break-words">{props.children}</span>
</p>
</Collapsible.Content>
</Collapsible>
@@ -65,7 +68,7 @@ function ReadToolPart(props: { part: ToolPart }) {
const path = state().input["filePath"] as string
return (
<Part class="cursor-pointer" onClick={() => local.file.open(path)}>
<span class="text-text-muted">Read</span> {getFilename(path)}
<span class="">Read</span> {getFilename(path)}
</Part>
)
}}
@@ -74,9 +77,9 @@ function ReadToolPart(props: { part: ToolPart }) {
{(state) => (
<div>
<Part>
<span class="text-text-muted">Read</span> {getFilename(state().input["filePath"] as string)}
<span class="">Read</span> {getFilename(state().input["filePath"] as string)}
</Part>
<div class="text-error">{sync.sanitize(state().error)}</div>
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
</div>
)}
</Match>
@@ -94,10 +97,9 @@ function EditToolPart(props: { part: ToolPart }) {
<Match when={props.part.state.status === "completed" && props.part.state}>
{(state) => (
<CollapsiblePart
defaultOpen
title={
<>
<span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
<span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
</>
}
>
@@ -110,11 +112,11 @@ function EditToolPart(props: { part: ToolPart }) {
<CollapsiblePart
title={
<>
<span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
<span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
</>
}
>
<div class="text-error">{sync.sanitize(state().error)}</div>
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
</CollapsiblePart>
)}
</Match>
@@ -134,7 +136,7 @@ function WriteToolPart(props: { part: ToolPart }) {
<CollapsiblePart
title={
<>
<span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
<span class="">Write</span> {getFilename(state().input["filePath"] as string)}
</>
}
>
@@ -146,9 +148,9 @@ function WriteToolPart(props: { part: ToolPart }) {
{(state) => (
<div>
<Part>
<span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
<span class="">Write</span> {getFilename(state().input["filePath"] as string)}
</Part>
<div class="text-error">{sync.sanitize(state().error)}</div>
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
</div>
)}
</Match>
@@ -169,7 +171,7 @@ function BashToolPart(props: { part: ToolPart }) {
defaultOpen
title={
<>
<span class="text-text-muted">Run command:</span> {state().input["command"]}
<span class="">Run command:</span> {state().input["command"]}
</>
}
>
@@ -182,11 +184,11 @@ function BashToolPart(props: { part: ToolPart }) {
<CollapsiblePart
title={
<>
<span class="text-text-muted">Shell</span> {state().input["command"]}
<span class="">Shell</span> {state().input["command"]}
</>
}
>
<div class="text-error">{sync.sanitize(state().error)}</div>
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
</CollapsiblePart>
)}
</Match>
@@ -209,7 +211,7 @@ function ToolPart(props: { part: ToolPart }) {
// patch
// task
return (
<div class="min-w-0 flex-auto text-xs">
<div class="min-w-0 flex-auto text-12-medium">
<Switch
fallback={
<span>
@@ -242,7 +244,32 @@ export default function SessionTimeline(props: { session: string; class?: string
const size = createElementSize(root)
const scroll = createScrollPosition(scrollElement)
onMount(() => sync.session.sync(props.session))
const valid = (part: Part) => {
if (!part) return false
switch (part.type) {
case "step-start":
case "step-finish":
case "file":
case "patch":
return false
case "text":
return !part.synthetic
case "reasoning":
return part.text.trim()
case "tool":
switch (part.tool) {
case "todoread":
case "todowrite":
case "list":
case "grep":
return false
}
return true
default:
return true
}
}
const session = createMemo(() => sync.session.get(props.session))
const messages = createMemo(() => sync.data.message[props.session] ?? [])
const working = createMemo(() => {
@@ -252,6 +279,45 @@ export default function SessionTimeline(props: { session: string; class?: string
return !last.time.completed
})
const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const last = createMemo(() => {
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
})
const model = createMemo(() => {
if (!last()) return
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
return model
})
const tokens = createMemo(() => {
if (!last()) return
const tokens = last().tokens
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
return new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(total)
})
const context = createMemo(() => {
if (!last()) return
if (!model()?.limit.context) return 0
const tokens = last().tokens
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
return Math.round((total / model()!.limit.context) * 100)
})
const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => {
let p = el?.parentElement
while (p && p !== document.body) {
@@ -293,23 +359,6 @@ export default function SessionTimeline(props: { session: string; class?: string
lastScrollY = scroll.y
})
const valid = (part: Part) => {
if (!part) return false
switch (part.type) {
case "step-start":
case "step-finish":
case "file":
case "patch":
return false
case "text":
return !part.synthetic
case "reasoning":
return part.text.trim()
default:
return true
}
}
const duration = (part: Part) => {
switch (part.type) {
default:
@@ -333,57 +382,66 @@ export default function SessionTimeline(props: { session: string; class?: string
<div
ref={setRoot}
classList={{
"p-4 select-text flex flex-col gap-y-1": true,
"select-text flex flex-col text-text-weak": true,
[props.class ?? ""]: !!props.class,
}}
>
<ul role="list" class="flex flex-col gap-1">
<div class="py-1.5 px-10 flex justify-end items-center self-stretch">
<div class="flex items-center gap-6">
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
<Show when={context()}>
<ProgressCircle percentage={context()!} />
</Show>
<div class="text-14-regular text-text-weak text-right">{context()}%</div>
</Tooltip>
<div class="text-14-regular text-text-strong text-right">{cost()}</div>
</div>
</div>
<ul role="list" class="flex flex-col gap-6 items-start self-stretch px-10 pt-2 pb-6">
<For each={messages()}>
{(message) => (
<For each={sync.data.part[message.id]?.filter(valid)}>
{(part) => (
<li class="group/li">
<Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}>
<Match when={part.type === "text" && part}>
{(part) => (
<Switch>
<Match when={message.role === "user"}>
<div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0 mt-5 group-first/li:mt-0">
<p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel">
<span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span>
</p>
<p class="text-xs text-text-muted">
{DateTime.fromMillis(message.time.created).toRelative()} ·{" "}
{sync.data.config.username ?? "user"}
</p>
</div>
</Match>
<Match when={message.role === "assistant"}>
<Markdown text={sync.sanitize(part().text)} class="text-text mt-1" />
</Match>
</Switch>
)}
</Match>
<Match when={part.type === "reasoning" && part}>
{(part) => (
<CollapsiblePart
title={
<Switch fallback={<span class="text-text-muted">Thinking</span>}>
<Match when={part().time.end}>
<span class="text-text-muted">Thought</span> for {duration(part())}s
</Match>
</Switch>
}
>
<Markdown text={part().text} />
</CollapsiblePart>
)}
</Match>
<Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
</Switch>
</li>
)}
</For>
<div class="flex flex-col gap-1 justify-center items-start self-stretch">
<For each={sync.data.part[message.id]?.filter(valid)}>
{(part) => (
<li class="group/li">
<Switch fallback={<div class="">{part.type}</div>}>
<Match when={part.type === "text" && part}>
{(part) => (
<Switch>
<Match when={message.role === "user"}>
<div class="w-fit flex items-center px-3 py-1 rounded-md bg-surface-weak">
<span class="text-14-regular text-text-strong whitespace-pre-wrap break-words">
{part().text}
</span>
</div>
</Match>
<Match when={message.role === "assistant"}>
<Markdown text={sync.sanitize(part().text)} />
</Match>
</Switch>
)}
</Match>
<Match when={part.type === "reasoning" && part}>
{(part) => (
<CollapsiblePart
title={
<Switch fallback={<span class="text-text-weak">Thinking</span>}>
<Match when={part().time.end}>
<span class="text-12-medium text-text-weak">Thought</span> for {duration(part())}s
</Match>
</Switch>
}
>
<Markdown text={part().text} />
</CollapsiblePart>
)}
</Match>
<Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
</Switch>
</li>
)}
</For>
</div>
)}
</For>
</ul>
@@ -1,48 +0,0 @@
import { For } from "solid-js"
import { Icon, Link, Logo, Tooltip } from "@/ui"
import { useLocation } from "@solidjs/router"
const navigation = [
{ name: "Sessions", href: "/sessions", icon: "dashboard" as const },
{ name: "Commands", href: "/commands", icon: "slash" as const },
{ name: "Agents", href: "/agents", icon: "bolt" as const },
{ name: "Providers", href: "/providers", icon: "cloud" as const },
{ name: "Tools (MCP)", href: "/tools", icon: "hammer" as const },
{ name: "LSP", href: "/lsp", icon: "code" as const },
{ name: "Settings", href: "/settings", icon: "settings" as const },
]
export default function SidebarNav() {
const location = useLocation()
return (
<div class="hidden md:fixed md:inset-y-0 md:left-0 md:z-50 md:block md:w-16 md:overflow-y-auto md:bg-background-panel md:pb-4">
<div class="flex h-16 shrink-0 items-center justify-center">
<Logo variant="mark" size={28} />
</div>
<nav class="mt-5">
<ul role="list" class="flex flex-col items-center space-y-1">
<For each={navigation}>
{(item) => (
<li>
<Tooltip placement="right" value={item.name}>
<Link
href={item.href}
classList={{
"bg-background-element text-text": location.pathname.startsWith(item.href),
"text-text-muted hover:bg-background-element hover:text-text": location.pathname !== item.href,
"flex gap-x-3 rounded-md p-3 text-sm font-semibold": true,
"focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-border-active": true,
}}
>
<Icon name={item.icon} size={20} />
<span class="sr-only">{item.name}</span>
</Link>
</Tooltip>
</li>
)}
</For>
</ul>
</nav>
</div>
)
}
-1
View File
@@ -4,4 +4,3 @@ export { MarkedProvider, useMarked } from "./marked"
export { SDKProvider, useSDK } from "./sdk"
export { ShikiProvider, useShiki } from "./shiki"
export { SyncProvider, useSync } from "./sync"
export { ThemeProvider, useTheme } from "./theme"
+54 -22
View File
@@ -131,28 +131,31 @@ function init() {
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
createEffect((prev: FileStatus[]) => {
const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
for (const p of removed) {
setStore(
"node",
p.path,
produce((draft) => {
draft.status = undefined
draft.view = "raw"
}),
)
load(p.path)
}
for (const p of sync.data.changes) {
if (store.node[p.path] === undefined) {
fetch(p.path).then(() => setStore("node", p.path, "status", p))
} else {
setStore("node", p.path, "status", p)
}
}
return sync.data.changes
}, sync.data.changes)
// createEffect((prev: FileStatus[]) => {
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
// for (const p of removed) {
// setStore(
// "node",
// p.path,
// produce((draft) => {
// draft.status = undefined
// draft.view = "raw"
// }),
// )
// load(p.path)
// }
// for (const p of sync.data.changes) {
// if (store.node[p.path] === undefined) {
// fetch(p.path).then(() => {
// if (store.node[p.path] === undefined) return
// setStore("node", p.path, "status", p)
// })
// } else {
// setStore("node", p.path, "status", p)
// }
// }
// return sync.data.changes
// }, sync.data.changes)
const changed = (path: string) => {
const node = store.node[path]
@@ -202,6 +205,13 @@ function init() {
}
}
const init = async (path: string) => {
const relativePath = relative(path)
if (!store.node[relativePath]) await fetch(path)
if (store.node[relativePath].loaded) return
return load(relativePath)
}
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
const relativePath = relative(path)
if (!store.node[relativePath]) await fetch(path)
@@ -271,6 +281,7 @@ function init() {
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
open,
load,
init,
close(path: string) {
setStore("opened", (opened) => opened.filter((x) => x !== path))
if (store.active === path) {
@@ -459,6 +470,11 @@ function init() {
return sync.session.get(store.active)
})
createEffect(() => {
if (!store.active) return
sync.session.sync(store.active)
})
return {
active,
setActive(sessionId: string | undefined) {
@@ -473,11 +489,16 @@ function init() {
const context = (() => {
const [store, setStore] = createStore<{
activeTab: boolean
files: string[]
activeFile?: string
items: (ContextItem & { key: string })[]
}>({
activeTab: true,
files: [],
items: [],
})
const files = createMemo(() => store.files.map((x) => file.node(x)))
const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
return {
all() {
@@ -505,6 +526,17 @@ function init() {
remove(key: string) {
setStore("items", (x) => x.filter((x) => x.key !== key))
},
files,
openFile(path: string) {
file.init(path).then(() => {
setStore("files", (x) => [...x, path])
setStore("activeFile", path)
})
},
activeFile,
setActiveFile(path: string | undefined) {
setStore("activeFile", path)
},
}
})()
+4 -1
View File
@@ -1,4 +1,4 @@
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode } from "@opencode-ai/sdk"
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js"
import { useSDK, useEvent } from "@/context"
@@ -9,6 +9,7 @@ function init() {
ready: boolean
provider: Provider[]
agent: Agent[]
project: Project
config: Config
path: Path
session: Session[]
@@ -21,6 +22,7 @@ function init() {
node: FileNode[]
changes: File[]
}>({
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
ready: false,
@@ -96,6 +98,7 @@ function init() {
const sdk = useSDK()
const load = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!)),
provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
-92
View File
@@ -1,92 +0,0 @@
import {
createContext,
useContext,
createSignal,
createEffect,
onMount,
type ParentComponent,
onCleanup,
} from "solid-js"
export interface ThemeContextValue {
theme: string | undefined
isDark: boolean
setTheme: (themeName: string) => void
setDarkMode: (isDark: boolean) => void
}
const ThemeContext = createContext<ThemeContextValue>()
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}
interface ThemeProviderProps {
defaultTheme?: string
defaultDarkMode?: boolean
}
const themes = ["opencode", "tokyonight", "ayu", "nord", "catppuccin"]
export const ThemeProvider: ParentComponent<ThemeProviderProps> = (props) => {
const [theme, setThemeSignal] = createSignal<string | undefined>()
const [isDark, setIsDark] = createSignal(props.defaultDarkMode ?? false)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "t" && event.ctrlKey) {
event.preventDefault()
const current = theme()
if (!current) return
const index = themes.indexOf(current)
const next = themes[(index + 1) % themes.length]
setTheme(next)
}
}
onMount(() => {
window.addEventListener("keydown", handleKeyDown)
})
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
onMount(() => {
const savedTheme = localStorage.getItem("theme") ?? "opencode"
const savedDarkMode = localStorage.getItem("darkMode") ?? "true"
setIsDark(savedDarkMode === "true")
setTheme(savedTheme)
})
createEffect(() => {
const currentTheme = theme()
const darkMode = isDark()
if (currentTheme) {
document.documentElement.setAttribute("data-theme", currentTheme)
document.documentElement.setAttribute("data-dark", darkMode.toString())
}
})
const setTheme = async (theme: string) => {
setThemeSignal(theme)
localStorage.setItem("theme", theme)
}
const setDarkMode = (dark: boolean) => {
setIsDark(dark)
localStorage.setItem("darkMode", dark.toString())
}
const contextValue: ThemeContextValue = {
theme: theme(),
isDark: isDark(),
setTheme,
setDarkMode,
}
return <ThemeContext.Provider value={contextValue}>{props.children}</ThemeContext.Provider>
}

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