Compare commits

...

143 Commits

Author SHA1 Message Date
Aaron Iker
b30c91de78 Merge branch 'dev' into update-design-subscriptions 2026-01-15 22:18:51 +01:00
Aaron Iker
2a22111b5e fix: small style adjustments, light rays params 2026-01-15 22:16:58 +01:00
Nhan Nguyen
f5fd54598f docs: add /thinking command documentation (#8722) 2026-01-15 15:14:23 -06:00
David Hill
0f7b17b1b4 fix: thinking animation opacity and design 2026-01-15 20:42:14 +00:00
David Hill
4d3e983edb fix: session icon and name alignment 2026-01-15 20:42:14 +00:00
Github Action
50badbd779 Update aarch64-darwin hash 2026-01-15 20:41:35 +00:00
Github Action
d3fc29bdec Update aarch64-darwin hash 2026-01-15 20:39:01 +00:00
Aaron Iker
fe58c649cb feat(console): Update /black plan selection, light rays effect. mobile styles (#8731)
Co-authored-by: Github Action <action@github.com>
2026-01-15 21:31:50 +01:00
Github Action
87eebad14e Update Nix flake.lock and x86_64-linux hash 2026-01-15 20:30:52 +00:00
Aaron Iker
e258662178 Merge branch 'dev' into update-design-subscriptions 2026-01-15 21:28:10 +01:00
Aaron Iker
591f54cd0d feat: light rays improvement, mobile styles 2026-01-15 21:26:46 +01:00
Aaron Iker
fdea599939 Merge branch 'update-design-subscriptions' of https://github.com/anomalyco/opencode into update-design-subscriptions 2026-01-15 21:15:07 +01:00
Adam
af2a09940c fix(core): more defensive project list 2026-01-15 13:58:39 -06:00
Adam
7e016fdda6 chore: cleanup 2026-01-15 13:34:53 -06:00
Adam
beb97d21ff fix(app): show session busy even for active session 2026-01-15 13:33:49 -06:00
Adam
b0345284f9 fix(core): filter dead worktrees 2026-01-15 13:33:49 -06:00
Adam
d71153eae6 fix(core): loading models.dev in dev 2026-01-15 13:33:48 -06:00
Aaron Iker
ccac97c7c4 feat: transition improvements 2026-01-15 20:23:22 +01:00
dbpolito
e60ded01df chore(desktop): Stop Killing opencode-cli on dev 2026-01-15 13:17:57 -06:00
dbpolito
4b2a14c154 chore(desktop): Question Tools Updates 2026-01-15 13:17:31 -06:00
David Hill
b4717d8092 bun/package.json updates
this may not be required
2026-01-15 19:15:21 +00:00
David Hill
dc8f8cc567 fix: current session background color 2026-01-15 19:15:21 +00:00
David Hill
99110d12c4 fix: remove the active state from load more button after press 2026-01-15 19:15:21 +00:00
David Hill
74b1349cf6 fix: new session tooltip position and add shortcut 2026-01-15 19:15:21 +00:00
David Hill
3b3505cfe8 fix: remove more options tooltip 2026-01-15 19:15:21 +00:00
David Hill
55bd6e487e fix: workspace name color 2026-01-15 19:15:21 +00:00
David Hill
1ee916a3c3 fix: hide view all sessions on active project 2026-01-15 19:15:21 +00:00
David Hill
a5d47f076e fix: avatar button states 2026-01-15 19:15:21 +00:00
David Hill
acd1eb574d fix: load more button font size 2026-01-15 19:15:21 +00:00
David Hill
a71dcc189e fix: recent sessions title color 2026-01-15 19:15:21 +00:00
David Hill
3789a31423 fix: project dropdown labels and order 2026-01-15 19:15:21 +00:00
David Hill
bb6e350d68 fix: move left panel toggle over
- not sure how this impacts on the titlebar when the traffic lights are there
2026-01-15 19:15:21 +00:00
David Hill
f9a441d4f4 fix: avatar background 2026-01-15 19:15:21 +00:00
David Hill
1c05ebaea2 fix: show project options on hover of row 2026-01-15 19:15:21 +00:00
David Hill
520c47e81d fix: increase delay on session list tooltips 2026-01-15 19:15:21 +00:00
David Hill
e5b08da0f1 fix: tooltip gutter spacing on session items and archive buttons 2026-01-15 19:15:21 +00:00
David Hill
fe2cc0cff1 fix: archive icon replaces diff count on hover 2026-01-15 19:15:21 +00:00
David Hill
fbc8f6eba9 fix: recent sessions hover gutter 2026-01-15 19:15:21 +00:00
David Hill
8cba7d7f53 fix: tooltips cleanup 2026-01-15 19:15:21 +00:00
David Hill
6450ba1b79 fix: search bar in header 2026-01-15 19:15:21 +00:00
Aiden Cline
dc1c25cff5 fix: ensure frontmatter can process same content as other agents (#8719) 2026-01-15 13:06:14 -06:00
Github Action
3f3550a16e Update aarch64-darwin hash 2026-01-15 18:29:11 +00:00
Github Action
57b457f568 Update aarch64-darwin hash 2026-01-15 18:22:50 +00:00
Github Action
161e3db795 Update Nix flake.lock and x86_64-linux hash 2026-01-15 18:17:44 +00:00
Aiden Cline
5a8a0f6a56 fix: downgrade bun to fix avx issue 2026-01-15 12:16:17 -06:00
Github Action
08068c3b91 Update Nix flake.lock and x86_64-linux hash 2026-01-15 18:13:32 +00:00
Aaron Iker
64edbb6b82 fix: webgp buffer 2026-01-15 19:12:17 +01:00
Aaron Iker
864f7ce129 feat: small style fixes, webgpu types 2026-01-15 19:08:24 +01:00
Aaron Iker
977827c9a4 feat: refacor light rays to WEBGPU 2026-01-15 19:08:08 +01:00
Aaron Iker
d8b8854795 feat: remove ogl, add webgpu types 2026-01-15 19:07:51 +01:00
Maciek Szczesniak
37f30993fa fix: show toast error message on ConfigMarkdown parse error (#8049)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-15 11:53:06 -06:00
opencode-agent[bot]
ebc194ca9a Prettify retry duration display in TUI (#8608)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-15 11:50:18 -06:00
andrew-kramer-inno
4edb4fa4fa fix: handle broken symlinks gracefully in grep tool (#8612)
Co-authored-by: Alex Johnson <nvidiattxpsli@gmail.com>
2026-01-15 11:40:37 -06:00
Aaron Iker
d79dc295fd Merge branch 'update-design-subscriptions' of https://github.com/anomalyco/opencode into update-design-subscriptions 2026-01-15 18:24:03 +01:00
Aaron Iker
abadacdce7 feat: small light rays tweaks 2026-01-15 18:22:12 +01:00
Github Action
bd5a9002a8 Update aarch64-darwin hash 2026-01-15 17:19:44 +00:00
Github Action
ecf33a72c3 Update Nix flake.lock and x86_64-linux hash 2026-01-15 17:12:46 +00:00
Aaron Iker
f2711bf5ae Merge branch 'dev' into update-design-subscriptions 2026-01-15 18:12:20 +01:00
Aaron Iker
769c34c94f fix: desktop shellOpen 2026-01-15 18:11:34 +01:00
Aryan "LAG" Gupta
63176bb049 docs: fix typos in documentation (#8703) 2026-01-15 11:06:16 -06:00
Aaron Iker
ad33807627 feat: update select plan UI 2026-01-15 17:52:59 +01:00
Aaron Iker
cf4fe5dc82 add light rays 2026-01-15 17:52:48 +01:00
Aaron Iker
56a7fbe131 feat: add ogl 2026-01-15 17:52:31 +01:00
GitHub Action
216a2d87cf chore: generate 2026-01-15 16:32:09 +00:00
Dax
dd1f981d23 fix: honor per-server MCP timeouts (#8706) 2026-01-15 11:31:31 -05:00
Sebastian Herrlinger
bfc9b24b48 use native text truncation for sidebar diff paths 2026-01-15 17:23:48 +01:00
Github Action
2691e1e666 Update aarch64-darwin hash 2026-01-15 15:47:53 +00:00
Github Action
3f16e0d89f Update Nix flake.lock and x86_64-linux hash 2026-01-15 15:41:26 +00:00
Sebastian Herrlinger
994c55f709 upgrade opentui to v0.1.73, fixing CJK word wrapping and thai text rendering (non-tmux) 2026-01-15 16:39:48 +01:00
Adam
2f32f2ceb5 chore: cleanup 2026-01-15 07:29:13 -06:00
Adam
076dfb3752 chore: cleanup 2026-01-15 07:29:13 -06:00
Github Action
60aa0cb96e Update Nix flake.lock and x86_64-linux hash 2026-01-15 07:29:13 -06:00
Adam
e5973e2860 chore: cleanup 2026-01-15 07:29:13 -06:00
Adam
dbd1987f0a chore: cleanup 2026-01-15 07:29:13 -06:00
Adam
f270ea65c5 fix(app): new layout issues 2026-01-15 07:29:13 -06:00
Adam
1698448016 fix(app): new layout sessions stale 2026-01-15 07:29:13 -06:00
Adam
564d3edfac fix(app): new layout issues 2026-01-15 07:29:13 -06:00
Adam
679270d9e0 feat(app): new layout 2026-01-15 07:29:13 -06:00
adamelmore
9f66a45970 feat(app): new layout 2026-01-15 07:29:13 -06:00
Aaron Iker
3bc995dbe1 feat: restore former layout 2026-01-15 10:56:39 +01:00
Turcu Laurentiu
779610d668 fix(desktop): open external links in system browser instead of webview (#7360) 2026-01-15 02:12:27 -06:00
Ryan Vogel
1fb611ef0a fix: enable sticky header on changelog and download pages (#8556) 2026-01-15 02:09:23 -06:00
GitHub Action
972f5ecc7d chore: generate 2026-01-15 07:35:52 +00:00
Brandon Smith
8d720f9463 fix(opencode): add input limit for compaction (#8465) 2026-01-15 01:35:16 -06:00
Aiden Cline
92931437c4 fix: codex id issue (#8605) 2026-01-15 01:31:50 -06:00
Ariane Emory
08ca1237cc fix(tui): Center the initially selected session in the session_list (resolves #8558) (#8560) 2026-01-15 01:04:20 -06:00
GitHub Action
6473e15793 chore: generate 2026-01-15 06:45:39 +00:00
Aiden Cline
16cac69a72 Revert "feat: allow provider-level store option (#8000)" (#8613) 2026-01-15 00:45:03 -06:00
GitHub Action
b2da41cfad chore: generate 2026-01-15 06:36:30 +00:00
Call White
fcf2da9571 feat: allow provider-level store option (#8000) 2026-01-15 00:35:53 -06:00
GitHub Action
253b7ea784 chore: generate 2026-01-15 06:04:47 +00:00
Kit Langton
3a9fd1bb36 fix: restore brand integrity of TUI wordmark (#8584) 2026-01-15 00:04:11 -06:00
GitHub Action
f84ac697dc chore: generate 2026-01-15 05:40:29 +00:00
Cas
76a79284d2 feat(tui): make dialog keybinds configurable (#6143) (#6144) 2026-01-14 23:39:52 -06:00
opencode
99a1e73fa1 release: v1.1.21 2026-01-15 02:34:07 +00:00
GitHub Action
ba4c86448b chore: generate 2026-01-15 02:21:43 +00:00
Aiden Cline
b36837ae93 tweak: add error message so people know to reauthenticate with copilot 2026-01-14 20:21:03 -06:00
Frank
e03932e586 zen: black usage 2026-01-14 21:20:26 -05:00
Idris Gadi
6b019a125a docs: fix permission system documentation in agents section (#7652) 2026-01-14 20:17:04 -06:00
Aiden Cline
6a2fed7042 chore: bump cache version 2026-01-14 17:44:16 -06:00
Aiden Cline
74baae597a chore: bump plugin version 2026-01-14 17:43:12 -06:00
Aiden Cline
d78d31430d feat: official copilot plugin (#8393) 2026-01-14 17:42:51 -06:00
Aiden Cline
096e14d787 tweak: adjust lsp wording a bit more to encourage fixing 2026-01-14 15:44:44 -06:00
Frank
bbb3120b59 zen: gpt-5.2-codex 2026-01-14 16:03:04 -05:00
Frank
9e4438f5bf wip: black 2026-01-14 16:03:04 -05:00
opencode-agent[bot]
87438fb38e ci: dedup stuff in changelog (#8522)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-14 14:34:57 -06:00
Dax Raad
6b6d6e9e07 update security 2026-01-14 14:52:39 -05:00
Dax Raad
207a59aad4 docs: add comprehensive security threat model and architecture documentation 2026-01-14 14:49:27 -05:00
Aiden Cline
b3ae1931fc fix: plan path permissions 2026-01-14 13:28:56 -06:00
Samiul Islam
4d08123ca0 feat(install): respect ZDOTDIR for zsh config detection (#8511)
Signed-off-by: sami <samiulsami7786@gmail.com>
2026-01-14 12:30:54 -06:00
Aiden Cline
7d3c7a9f65 add check incase provider doesnt exist in models list 2026-01-14 12:16:12 -06:00
Aiden Cline
50dfa9caf3 chore: upgrade bun from 1.3.5 -> 1.3.6, also update types/bun from 1.3.4 -> 1.3.6 and fix type errs (#8499)
Co-authored-by: Github Action <action@github.com>
2026-01-14 11:53:12 -06:00
Aiden Cline
1f86aa8bb9 fix: adjust gitlab logic in provider.ts 2026-01-14 11:42:45 -06:00
GitHub Action
d83756eaaf chore: generate 2026-01-14 17:41:16 +00:00
Aiden Cline
c0b43d3cb4 ignore: add slash command to checks ai sdk deps 2026-01-14 11:40:36 -06:00
Ryan Vogel
3206ed47e0 feat(console): add OG image and SEO meta tags for /black page (#8506) 2026-01-14 11:20:50 -06:00
Ryan Vogel
346c5e0da6 fix(console): make logo on /black link back to homepage (#8498) 2026-01-14 11:49:44 -05:00
Dax Raad
5b431c36f8 ignore: remove nowrap constraint to allow text wrapping in console UI 2026-01-14 11:39:52 -05:00
Dax Raad
44d24d42b8 ignore: fix auth redirect to preserve selected plan during subscription flow 2026-01-14 11:25:50 -05:00
Jacopo Binosi
3a9e6b558c feat(opencode): add AWS Web Identity Token File support for Bedrock (#8461) 2026-01-14 10:20:47 -06:00
Dax Raad
9d92ae7530 copy changes 2026-01-14 11:17:11 -05:00
Ryan Vogel
e6e7eaf6e0 docs: add Web usage page (#8482) 2026-01-14 10:03:48 -06:00
Aaron Iker
8ce5c2b900 feat(console/app): Style changes, view transitions, small improvements (#8481) 2026-01-14 10:02:18 -06:00
Ryan Vogel
78be8fecdc feat(console): add /changelog page (#8476) 2026-01-14 10:01:30 -06:00
Github Action
b5e9f96660 Update aarch64-darwin hash 2026-01-14 15:39:39 +00:00
Mani Sundararajan
ad17e8d1f0 feat: add choco and scoop to opencode upgrade methods (#8439) 2026-01-14 09:39:01 -06:00
Ryan Vogel
b75d4d1c5e docs: update screenshot images (#8479) 2026-01-14 09:36:10 -06:00
Github Action
cc67bc005d Update Nix flake.lock and x86_64-linux hash 2026-01-14 15:35:07 +00:00
Vladimir Glafirov
0ce849c3d5 chore: update gitlab-ai-provider to 3.1.1 and remove unused parameter (#8424) 2026-01-14 09:34:02 -06:00
Cas
6e13e2f74e fix(session): remove typo'd duplicate path import (#8408) (#8412)
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-01-14 09:04:03 -06:00
GitHub Action
9fd61aef6e chore: generate 2026-01-14 15:01:42 +00:00
Ryan Vogel
bb3926bf45 fix(homepage): Update FAQ to include desktop and web links (#8453) 2026-01-14 09:01:02 -06:00
Kit Langton
b2b123a392 feat(tui): improve question prompt UX (#8339) 2026-01-14 10:00:29 -05:00
opencode
09ff3b9bb9 release: v1.1.20 2026-01-14 13:41:08 +00:00
GitHub Action
2256362ba2 chore: generate 2026-01-14 13:36:44 +00:00
Shane Bishop
077ca4454f fix(desktop): "load more" button behavior in desktop sidebar (#8430) 2026-01-14 07:36:08 -06:00
Andrew Jazbec
05cbb11709 fix(ui): layout-bottom icons (#8330) 2026-01-14 07:25:09 -06:00
Dax Raad
fcc561ebb7 fix plan mode when not in git worktree 2026-01-14 08:21:26 -05:00
Filip
ee6ca104e5 fix(app): file listing (#8309) 2026-01-14 07:09:36 -06:00
GitHub Action
4347a77d89 ignore: update download stats 2026-01-14 2026-01-14 12:05:15 +00:00
GitHub Action
76b10d85ee chore: generate 2026-01-14 07:37:11 +00:00
Goni Zahavy
45a770cdb1 fix(opencode): fix docker image after sst rename in tips (#8376) 2026-01-14 01:36:36 -06:00
Akshar Patel
a57c8669b6 feat: show connected providers in /connect dialog (#8351) 2026-01-14 01:35:59 -06:00
149 changed files with 5872 additions and 1600 deletions

View File

@@ -0,0 +1,24 @@
---
description: "Bump AI sdk dependencies minor / patch versions only"
---
Please read @package.json and @packages/opencode/package.json.
Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes).
I want a report of every dependency and the version that can be upgraded to.
What would be even better is if you can give me links to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added.
Consider using subagents for each dep to save your context window.
Here is a short list of some deps (please be comprehensive tho):
- "ai"
- "@ai-sdk/openai"
- "@ai-sdk/anthropic"
- "@openrouter/ai-sdk-provider"
- etc, etc
DO NOT upgrade the dependencies yet, just make a list of all dependencies and their versions that can be upgraded to minor or patch versions only.
Write up your findings to ai-sdk-updates.md

View File

@@ -1,3 +1,32 @@
# Security
## Threat Model
### Overview
OpenCode is an AI-powered coding assistant that runs locally on your machine. It provides an agent system with access to powerful tools including shell execution, file operations, and web access.
### No Sandbox
OpenCode does **not** sandbox the agent. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking - it prompts for confirmation before executing commands, writing files, etc. However, it is not designed to provide security isolation.
If you need true isolation, run OpenCode inside a Docker container or VM.
### Server Mode
Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server - any functionality it provides is not a vulnerability.
### Out of Scope
| Category | Rationale |
| ------------------------------- | ----------------------------------------------------------------------- |
| **Server access when opted-in** | If you enable server mode, API access is expected behavior |
| **Sandbox escapes** | The permission system is not a sandbox (see above) |
| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies |
| **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
---
# Reporting Security Issues
We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.

View File

@@ -200,3 +200,4 @@
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
| 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) |
| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) |

View File

@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -95,13 +95,14 @@
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"@webgpu/types": "0.1.54",
"typescript": "catalog:",
"wrangler": "4.50.0",
},
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -128,7 +129,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -152,7 +153,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -176,7 +177,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -205,7 +206,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -234,7 +235,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -250,7 +251,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.19",
"version": "1.1.21",
"bin": {
"opencode": "./bin/opencode",
},
@@ -278,7 +279,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.0",
"@gitlab/gitlab-ai-provider": "3.1.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -290,8 +291,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.72",
"@opentui/solid": "0.1.72",
"@opentui/core": "0.1.73",
"@opentui/solid": "0.1.73",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -354,7 +355,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -374,7 +375,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.19",
"version": "1.1.21",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -385,7 +386,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -398,7 +399,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -410,7 +411,7 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"dompurify": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
@@ -438,7 +439,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"zod": "catalog:",
},
@@ -449,7 +450,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.19",
"version": "1.1.21",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -505,7 +506,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.4",
"@types/bun": "1.3.5",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -913,7 +914,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-S0MVXsogrwbOboA/8L0CY5sBXg2HrrO8gdeUeHd9yLZDPsggFD0FzcSuzO5vBO6geUOpruRa8Hqrbb6WWu7Frw=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -1215,21 +1216,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.72", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.72", "@opentui/core-darwin-x64": "0.1.72", "@opentui/core-linux-arm64": "0.1.72", "@opentui/core-linux-x64": "0.1.72", "@opentui/core-win32-arm64": "0.1.72", "@opentui/core-win32-x64": "0.1.72", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-l4WQzubBJ80Q0n77Lxuodjwwm8qj/sOa7IXxEAzzDDXY/7bsIhdSpVhRTt+KevBRlok5J+w/KMKYr8UzkA4/hA=="],
"@opentui/core": ["@opentui/core@0.1.73", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.73", "@opentui/core-darwin-x64": "0.1.73", "@opentui/core-linux-arm64": "0.1.73", "@opentui/core-linux-x64": "0.1.73", "@opentui/core-win32-arm64": "0.1.73", "@opentui/core-win32-x64": "0.1.73", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-1OqLlArzUh3QjrYXGro5WKNgoCcacGJaaFvwOHg5lAOoSigFQRiqEUEEJLbSo3pyV8u7XEdC3M0rOP6K+oThzw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.73", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Xnc8S6kGIVcdwqqTq6jk50UVe1QtOXp+B0v4iH85iNW1Ljf198OoA7RcVA+edFb6o01PVwnhIIPtpkB/A4710w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.73", "", { "os": "darwin", "cpu": "x64" }, "sha512-RlgxQxu+kxsCZzeXRnpYrqbrpxbG8M/lnDf4sTPWmhXUiuDvY5BdB4YiBY5bv8eNdJ1j9HiMLtx6ZxElEviidA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.73", "", { "os": "linux", "cpu": "arm64" }, "sha512-9I88BdZMB3qtDPtDzFTg1EEt6sAGFSpOEmIIMB3MhqZqoq9+WSEyJZxM0/kff5vt4RJnqG7vz4fKMVRwNrUPGA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.73", "", { "os": "linux", "cpu": "x64" }, "sha512-50cGZkCh/i3nzijsjUnkmtWJtnJ6l9WpdIwSJsO2Id7nZdzupT1b6AkgGZdOgNl23MHXpAitmb+MhEAjAimCRA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.73", "", { "os": "win32", "cpu": "arm64" }, "sha512-mFiEeoiim5cmi6qu8CDfeecl9ivuMilfby/GnqTsr9G8e52qfT6nWF2m9Nevh9ebhXK+D/VnVhJIbObc0WIchA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.73", "", { "os": "win32", "cpu": "x64" }, "sha512-vzWHUi2vgwImuyxl+hlmK0aeCbnwozeuicIcHJE0orPOwp2PAKyR9WO330szAvfIO5ZPbNkjWfh6xIYnASM0lQ=="],
"@opentui/solid": ["@opentui/solid@0.1.72", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.72", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-hytoLPboL/MTY/BQUnf/HlBuNXTVONney0X+PIQI82wT7kMx7+HHI2wnowpM3dyvA7l6NfORSud2cs9kIUBFBw=="],
"@opentui/solid": ["@opentui/solid@0.1.73", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.73", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-FBSTiuWl+hHqFxmrJfC93cbJ0PJ4QoFbvRFuD6Gzrea5rH+G7BidjyI8YZuCcNnriDuIYaXTJdvBqe15lgKR1A=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1773,7 +1774,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -1903,7 +1904,7 @@
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@webgpu/types": ["@webgpu/types@0.1.66", "", {}, "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA=="],
"@webgpu/types": ["@webgpu/types@0.1.54", "", {}, "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg=="],
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
@@ -2075,7 +2076,7 @@
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@@ -4291,6 +4292,8 @@
"body-parser/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
"bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.66", "", {}, "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA=="],
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1768302833,
"narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=",
"lastModified": 1768395095,
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "61db79b0c6b838d9894923920b612048e1201926",
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
"type": "github"
},
"original": {

View File

@@ -369,7 +369,7 @@ case $current_shell in
config_files="$HOME/.config/fish/config.fish"
;;
zsh)
config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
config_files="${ZDOTDIR:-$HOME}/.zshrc ${ZDOTDIR:-$HOME}/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
;;
bash)
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"

View File

@@ -1,6 +1,6 @@
{
"nodeModules": {
"x86_64-linux": "sha256-wENwhwRVfgoVyA9YNGcG+fAfu46JxK4xvNgiPbRt//s=",
"aarch64-darwin": "sha256-vm1DYl1erlbaqz5NHHlnZEMuFmidr/UkS84nIqLJ96Q="
"x86_64-linux": "sha256-Fl1BdjNSg19LJVSgDMiBX8JuTaGlL2I5T+rqLfjSeO4=",
"aarch64-darwin": "sha256-7UajHu40n7JKqurU/+CGlitErsVFA2qDneUytI8+/zQ="
}
}

View File

@@ -21,7 +21,7 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.4",
"@types/bun": "1.3.5",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",

View File

@@ -13,12 +13,11 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh"></div>
<div id="root" class="flex flex-col h-dvh p-px"></div>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.19",
"version": "1.1.21",
"description": "",
"type": "module",
"exports": {

View File

@@ -1,267 +1,206 @@
import { createMemo, createResource, Show } from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router"
import { Portal } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
// import { useServer } from "@/context/server"
// import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { base64Decode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Select } from "@opencode-ai/ui/select"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { same } from "@/utils/same"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const command = useCommand()
const server = useServer()
const dialog = useDialog()
// const server = useServer()
// const dialog = useDialog()
const sync = useSync()
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
const parentSession = createMemo(() => {
const current = currentSession()
if (!current?.parentID) return undefined
return sync.data.session.find((s) => s.id === current.parentID)
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const name = createMemo(() => {
const current = project()
if (current) return current.name || getFilename(current.worktree)
return getFilename(projectDirectory())
})
const hotkey = createMemo(() => command.keybind("file.open"))
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey()))
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
}
function navigateToSession(session: Session | undefined) {
if (!session) return
// Only navigate if we're actually changing to a different session
if (session.id === params.id) return
navigate(`/${params.dir}/session/${session.id}`)
}
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex">
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
onClick={layout.mobileSidebar.toggle}
>
<Icon name="menu" size="small" />
</button>
<div class="px-4 flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={worktrees()}
current={sync.project?.worktree ?? projectDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
</div>
<Show
when={parentSession()}
fallback={
<>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</>
}
<>
<Show when={centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<button
type="button"
class="hidden md:flex w-[320px] h-8 p-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
onClick={() => command.trigger("file.open")}
>
<div class="flex items-center gap-2 min-w-0">
<Select
options={sessions()}
current={parentSession()}
placeholder="Back to parent session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={(session) => {
// Only navigate if selecting a different session than current parent
const currentParent = parentSession()
if (session && currentParent && session.id !== currentParent.id) {
navigateToSession(session)
}
}}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
<div class="text-text-weaker">/</div>
<div class="flex items-center gap-1.5 min-w-0">
<Tooltip value="Back to parent session">
<button
type="button"
class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0"
onClick={() => navigateToSession(parentSession())}
>
<Icon name="arrow-left" size="small" class="text-icon-base" />
</button>
</Tooltip>
</div>
<div class="flex items-center gap-2">
<Icon name="magnifying-glass" size="normal" class="icon-base" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span>
</div>
</Show>
</div>
<Show when={currentSession() && !parentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind>
</Show>
</div>
<div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
<Show when={hotkey()}>
{(keybind) => (
<span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-[2px] border border-border-weak-base bg-surface-base text-12-medium text-text-weak">
{keybind()}
</span>
)}
</Show>
</button>
</Portal>
)}
</Show>
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-3">
{/* <div class="hidden md:flex items-center gap-1"> */}
{/* <Button */}
{/* size="small" */}
{/* variant="ghost" */}
{/* onClick={() => { */}
{/* dialog.show(() => <DialogSelectServer />) */}
{/* }} */}
{/* > */}
{/* <div */}
{/* classList={{ */}
{/* "size-1.5 rounded-full": true, */}
{/* "bg-icon-success-base": server.healthy() === true, */}
{/* "bg-icon-critical-base": server.healthy() === false, */}
{/* "bg-border-weak-base": server.healthy() === undefined, */}
{/* }} */}
{/* /> */}
{/* <Icon name="server" size="small" class="text-icon-weak" /> */}
{/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */}
{/* </Button> */}
{/* <SessionLspIndicator /> */}
{/* <SessionMcpIndicator /> */}
{/* </div> */}
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().terminal.toggle()}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</div>
</div>
</header>
<Button
variant="ghost"
class="group/terminal-toggle size-6 p-0"
onClick={() => view().terminal.toggle()}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</div>
</Portal>
)}
</Show>
</>
)
}

View File

@@ -0,0 +1,115 @@
import { createEffect, createMemo, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
const theme = useTheme()
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const reserve = createMemo(
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
)
const getWin = () => {
if (platform.platform !== "desktop") return
const tauri = (
window as unknown as {
__TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise<void> } } }
}
).__TAURI__
if (!tauri?.window?.getCurrentWindow) return
return tauri.window.getCurrentWindow()
}
createEffect(() => {
if (platform.platform !== "desktop") return
const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme
const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } })
.__TAURI__
const get = tauri?.webviewWindow?.getCurrentWebviewWindow
if (!get) return
const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
if (!win.setTheme) return
void win.setTheme(value).catch(() => undefined)
})
const interactive = (target: EventTarget | null) => {
if (!(target instanceof Element)) return false
const selector =
"button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']"
return !!target.closest(selector)
}
const drag = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (e.buttons !== 1) return
if (interactive(e.target)) return
const win = getWin()
if (!win?.startDragging) return
e.preventDefault()
void win.startDragging().catch(() => undefined)
}
return (
<header class="h-10 shrink-0 bg-background-base flex items-center relative">
<div
classList={{
"flex items-center w-full min-w-0 pr-2": true,
"pl-2": !mac(),
}}
onMouseDown={drag}
>
<Show when={mac()}>
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
</Show>
<IconButton
icon="menu"
variant="ghost"
class="xl:hidden size-8 rounded-md"
onClick={layout.mobileSidebar.toggle}
/>
<TooltipKeybind
class="hidden xl:flex shrink-0 ml-14"
placement="bottom"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
>
<IconButton
icon={layout.sidebar.opened() ? "layout-left" : "layout-right"}
variant="ghost"
class="size-8 rounded-md"
onClick={layout.sidebar.toggle}
/>
</TooltipKeybind>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="flex-1 h-full" data-tauri-drag-region />
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0" />
<Show when={reserve()}>
<div class="w-[120px] h-full shrink-0" data-tauri-drag-region />
</Show>
</div>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto" />
</div>
</header>
)
}

View File

@@ -38,6 +38,7 @@ type State = {
config: Config
path: Path
session: Session[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionStatus
}
@@ -98,6 +99,7 @@ function createGlobalSync() {
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
@@ -117,21 +119,32 @@ function createGlobalSync() {
async function loadSessions(directory: string) {
const [store, setStore] = child(directory)
globalSDK.client.session
.list({ directory })
const limit = store.limit
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory))
if (sandboxWorkspace) {
setStore("session", reconcile(nonArchived, { key: "id" }))
return
}
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
// Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => {
if (i < store.limit) return true
if (i < limit) return true
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
return updated > fourHoursAgo
})
// Store total session count (used for "load more" pagination)
setStore("sessionTotal", nonArchived.length)
setStore("session", reconcile(sessions, { key: "id" }))
})
.catch((err) => {

View File

@@ -47,12 +47,34 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync()
const server = useServer()
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const migrate = (value: unknown) => {
if (!isRecord(value)) return value
const sidebar = value.sidebar
if (!isRecord(sidebar)) return value
if (typeof sidebar.workspaces !== "boolean") return value
return {
...value,
sidebar: {
...sidebar,
workspaces: {},
workspacesDefault: sidebar.workspaces,
},
}
}
const target = Persist.global("layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
Persist.global("layout", ["layout.v6"]),
{ ...target, migrate },
createStore({
sidebar: {
opened: false,
width: 280,
workspaces: {} as Record<string, boolean>,
workspacesDefault: false,
},
terminal: {
height: 280,
@@ -304,6 +326,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
resize(width: number) {
setStore("sidebar", "width", width)
},
workspaces(directory: string) {
return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false)
},
setWorkspaces(directory: string, value: boolean) {
setStore("sidebar", "workspaces", directory, value)
},
toggleWorkspaces(directory: string) {
const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
setStore("sidebar", "workspaces", directory, !current)
},
},
terminal: {
height: createMemo(() => store.terminal.height),

View File

@@ -5,6 +5,9 @@ export type Platform = {
/** Platform discriminator */
platform: "web" | "desktop"
/** Desktop OS (Tauri only) */
os?: "macos" | "windows" | "linux"
/** App version */
version?: string

View File

@@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sdk = useSDK()
const [store, setStore] = globalSync.child(sdk.directory)
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
const chunk = 200
const chunk = 400
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()

View File

@@ -5,3 +5,7 @@
cursor: default;
}
}
*[data-tauri-drag-region] {
app-region: drag;
}

File diff suppressed because it is too large Load Diff

View File

@@ -885,6 +885,19 @@ export default function Page() {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
if (!root) {
el.scrollIntoView({ behavior, block: "start" })
return
}
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior })
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setActiveMessage(message)
@@ -896,7 +909,7 @@ export default function Page() {
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" })
if (el) scrollToElement(el, behavior)
})
updateHash(message.id)
@@ -904,7 +917,7 @@ export default function Page() {
}
const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" })
if (el) scrollToElement(el, behavior)
updateHash(message.id)
}
@@ -956,7 +969,7 @@ export default function Page() {
const hashTarget = document.getElementById(hash)
if (hashTarget) {
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
scrollToElement(hashTarget, "auto")
return
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.19",
"version": "1.1.21",
"type": "module",
"license": "MIT",
"scripts": {
@@ -34,6 +34,7 @@
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"@webgpu/types": "0.1.54",
"typescript": "catalog:",
"wrangler": "4.50.0"
},

View File

@@ -0,0 +1 @@
../../../ui/src/assets/images/social-share-black.png

View File

@@ -24,6 +24,9 @@ export function Footer() {
<div data-slot="cell">
<a href="/docs">Docs</a>
</div>
<div data-slot="cell">
<a href="/changelog">Changelog</a>
</div>
<div data-slot="cell">
<a href="/discord">Discord</a>
</div>

View File

@@ -0,0 +1,186 @@
.light-rays-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
}
.light-rays-container canvas {
display: block;
width: 100%;
height: 100%;
}
.light-rays-controls {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
font-family: var(--font-mono, monospace);
font-size: 12px;
color: #fff;
}
.light-rays-controls-toggle {
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 8px 12px;
color: #fff;
cursor: pointer;
font-family: inherit;
font-size: inherit;
width: 100%;
text-align: left;
}
.light-rays-controls-toggle:hover {
background: rgba(0, 0, 0, 0.9);
border-color: rgba(255, 255, 255, 0.3);
}
.light-rays-controls-panel {
background: rgba(0, 0, 0, 0.85);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 12px;
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 240px;
max-height: calc(100vh - 100px);
overflow-y: auto;
backdrop-filter: blur(8px);
}
.control-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.control-group label {
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-group.checkbox {
flex-direction: row;
align-items: center;
}
.control-group.checkbox label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
text-transform: none;
}
.control-group input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
outline: none;
}
.control-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s;
}
.control-group input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.control-group input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
cursor: pointer;
border: none;
}
.control-group input[type="color"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 32px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: transparent;
cursor: pointer;
padding: 2px;
}
.control-group input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.control-group input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.control-group select {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 6px 8px;
color: #fff;
font-family: inherit;
font-size: inherit;
cursor: pointer;
outline: none;
}
.control-group select:hover {
border-color: rgba(255, 255, 255, 0.3);
}
.control-group select option {
background: #1a1a1a;
color: #fff;
}
.control-group input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #fff;
cursor: pointer;
}
.reset-button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 8px 12px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-family: inherit;
font-size: inherit;
margin-top: 4px;
transition: all 0.15s;
}
.reset-button:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
color: #fff;
}

View File

@@ -0,0 +1,924 @@
import { createSignal, createEffect, onMount, onCleanup, Show, For, Accessor, Setter } from "solid-js"
import "./light-rays.css"
export type RaysOrigin =
| "top-center"
| "top-left"
| "top-right"
| "right"
| "left"
| "bottom-center"
| "bottom-right"
| "bottom-left"
export interface LightRaysConfig {
raysOrigin: RaysOrigin
raysColor: string
raysSpeed: number
lightSpread: number
rayLength: number
sourceWidth: number
pulsating: boolean
pulsatingMin: number
pulsatingMax: number
fadeDistance: number
saturation: number
followMouse: boolean
mouseInfluence: number
noiseAmount: number
distortion: number
opacity: number
}
export const defaultConfig: LightRaysConfig = {
raysOrigin: "top-center",
raysColor: "#ffffff",
raysSpeed: 1.0,
lightSpread: 1.2,
rayLength: 4.5,
sourceWidth: 0.1,
pulsating: true,
pulsatingMin: 0.9,
pulsatingMax: 1.05,
fadeDistance: 1.25,
saturation: 0.35,
followMouse: false,
mouseInfluence: 0.05,
noiseAmount: 0.5,
distortion: 0.0,
opacity: 0.35,
}
export interface LightRaysAnimationState {
time: number
intensity: number
pulseValue: number
}
interface LightRaysProps {
config: Accessor<LightRaysConfig>
class?: string
onAnimationFrame?: (state: LightRaysAnimationState) => void
}
const hexToRgb = (hex: string): [number, number, number] => {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return m ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255] : [1, 1, 1]
}
const getAnchorAndDir = (
origin: RaysOrigin,
w: number,
h: number,
): { anchor: [number, number]; dir: [number, number] } => {
const outside = 0.2
switch (origin) {
case "top-left":
return { anchor: [0, -outside * h], dir: [0, 1] }
case "top-right":
return { anchor: [w, -outside * h], dir: [0, 1] }
case "left":
return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] }
case "right":
return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] }
case "bottom-left":
return { anchor: [0, (1 + outside) * h], dir: [0, -1] }
case "bottom-center":
return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] }
case "bottom-right":
return { anchor: [w, (1 + outside) * h], dir: [0, -1] }
default: // "top-center"
return { anchor: [0.5 * w, -outside * h], dir: [0, 1] }
}
}
interface UniformData {
iTime: number
iResolution: [number, number]
rayPos: [number, number]
rayDir: [number, number]
raysColor: [number, number, number]
raysSpeed: number
lightSpread: number
rayLength: number
sourceWidth: number
pulsating: number
pulsatingMin: number
pulsatingMax: number
fadeDistance: number
saturation: number
mousePos: [number, number]
mouseInfluence: number
noiseAmount: number
distortion: number
}
const WGSL_SHADER = `
struct Uniforms {
iTime: f32,
_pad0: f32,
iResolution: vec2<f32>,
rayPos: vec2<f32>,
rayDir: vec2<f32>,
raysColor: vec3<f32>,
raysSpeed: f32,
lightSpread: f32,
rayLength: f32,
sourceWidth: f32,
pulsating: f32,
pulsatingMin: f32,
pulsatingMax: f32,
fadeDistance: f32,
saturation: f32,
mousePos: vec2<f32>,
mouseInfluence: f32,
noiseAmount: f32,
distortion: f32,
_pad1: f32,
_pad2: f32,
_pad3: f32,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) vUv: vec2<f32>,
};
@vertex
fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
var positions = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0)
);
var output: VertexOutput;
let pos = positions[vertexIndex];
output.position = vec4<f32>(pos, 0.0, 1.0);
output.vUv = pos * 0.5 + 0.5;
return output;
}
fn noise(st: vec2<f32>) -> f32 {
return fract(sin(dot(st, vec2<f32>(12.9898, 78.233))) * 43758.5453123);
}
fn rayStrength(raySource: vec2<f32>, rayRefDirection: vec2<f32>, coord: vec2<f32>,
seedA: f32, seedB: f32, speed: f32) -> f32 {
let sourceToCoord = coord - raySource;
let dirNorm = normalize(sourceToCoord);
let cosAngle = dot(dirNorm, rayRefDirection);
let distortedAngle = cosAngle + uniforms.distortion * sin(uniforms.iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;
let spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(uniforms.lightSpread, 0.001));
let distance = length(sourceToCoord);
let maxDistance = uniforms.iResolution.x * uniforms.rayLength;
let lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);
let fadeFalloff = clamp((uniforms.iResolution.x * uniforms.fadeDistance - distance) / (uniforms.iResolution.x * uniforms.fadeDistance), 0.5, 1.0);
let pulseCenter = (uniforms.pulsatingMin + uniforms.pulsatingMax) * 0.5;
let pulseAmplitude = (uniforms.pulsatingMax - uniforms.pulsatingMin) * 0.5;
var pulse: f32;
if (uniforms.pulsating > 0.5) {
pulse = pulseCenter + pulseAmplitude * sin(uniforms.iTime * speed * 3.0);
} else {
pulse = 1.0;
}
let baseStrength = clamp(
(0.45 + 0.15 * sin(distortedAngle * seedA + uniforms.iTime * speed)) +
(0.3 + 0.2 * cos(-distortedAngle * seedB + uniforms.iTime * speed)),
0.0, 1.0
);
return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;
}
@fragment
fn fragmentMain(@builtin(position) fragCoord: vec4<f32>, @location(0) vUv: vec2<f32>) -> @location(0) vec4<f32> {
let coord = vec2<f32>(fragCoord.x, fragCoord.y);
let normalizedX = (coord.x / uniforms.iResolution.x) - 0.5;
let widthOffset = -normalizedX * uniforms.sourceWidth * uniforms.iResolution.x;
let perpDir = vec2<f32>(-uniforms.rayDir.y, uniforms.rayDir.x);
let adjustedRayPos = uniforms.rayPos + perpDir * widthOffset;
var finalRayDir = uniforms.rayDir;
if (uniforms.mouseInfluence > 0.0) {
let mouseScreenPos = uniforms.mousePos * uniforms.iResolution;
let mouseDirection = normalize(mouseScreenPos - adjustedRayPos);
finalRayDir = normalize(mix(uniforms.rayDir, mouseDirection, uniforms.mouseInfluence));
}
let rays1 = vec4<f32>(1.0) *
rayStrength(adjustedRayPos, finalRayDir, coord, 36.2214, 21.11349,
1.5 * uniforms.raysSpeed);
let rays2 = vec4<f32>(1.0) *
rayStrength(adjustedRayPos, finalRayDir, coord, 22.3991, 18.0234,
1.1 * uniforms.raysSpeed);
var fragColor = rays1 * 0.5 + rays2 * 0.4;
if (uniforms.noiseAmount > 0.0) {
let n = noise(coord * 0.01 + uniforms.iTime * 0.1);
fragColor = vec4<f32>(fragColor.rgb * (1.0 - uniforms.noiseAmount + uniforms.noiseAmount * n), fragColor.a);
}
let brightness = 1.0 - (coord.y / uniforms.iResolution.y);
fragColor.x = fragColor.x * (0.1 + brightness * 0.8);
fragColor.y = fragColor.y * (0.3 + brightness * 0.6);
fragColor.z = fragColor.z * (0.5 + brightness * 0.5);
if (uniforms.saturation != 1.0) {
let gray = dot(fragColor.rgb, vec3<f32>(0.299, 0.587, 0.114));
fragColor = vec4<f32>(mix(vec3<f32>(gray), fragColor.rgb, uniforms.saturation), fragColor.a);
}
fragColor = vec4<f32>(fragColor.rgb * uniforms.raysColor, fragColor.a);
return fragColor;
}
`
const UNIFORM_BUFFER_SIZE = 96
function createUniformBuffer(data: UniformData): Float32Array {
const buffer = new Float32Array(24)
buffer[0] = data.iTime
buffer[1] = 0
buffer[2] = data.iResolution[0]
buffer[3] = data.iResolution[1]
buffer[4] = data.rayPos[0]
buffer[5] = data.rayPos[1]
buffer[6] = data.rayDir[0]
buffer[7] = data.rayDir[1]
buffer[8] = data.raysColor[0]
buffer[9] = data.raysColor[1]
buffer[10] = data.raysColor[2]
buffer[11] = data.raysSpeed
buffer[12] = data.lightSpread
buffer[13] = data.rayLength
buffer[14] = data.sourceWidth
buffer[15] = data.pulsating
buffer[16] = data.pulsatingMin
buffer[17] = data.pulsatingMax
buffer[18] = data.fadeDistance
buffer[19] = data.saturation
buffer[20] = data.mousePos[0]
buffer[21] = data.mousePos[1]
buffer[22] = data.mouseInfluence
buffer[23] = data.noiseAmount
return buffer
}
const UNIFORM_BUFFER_SIZE_CORRECTED = 112
function createUniformBufferCorrected(data: UniformData): Float32Array {
const buffer = new Float32Array(28)
buffer[0] = data.iTime
buffer[1] = 0
buffer[2] = data.iResolution[0]
buffer[3] = data.iResolution[1]
buffer[4] = data.rayPos[0]
buffer[5] = data.rayPos[1]
buffer[6] = data.rayDir[0]
buffer[7] = data.rayDir[1]
buffer[8] = data.raysColor[0]
buffer[9] = data.raysColor[1]
buffer[10] = data.raysColor[2]
buffer[11] = data.raysSpeed
buffer[12] = data.lightSpread
buffer[13] = data.rayLength
buffer[14] = data.sourceWidth
buffer[15] = data.pulsating
buffer[16] = data.pulsatingMin
buffer[17] = data.pulsatingMax
buffer[18] = data.fadeDistance
buffer[19] = data.saturation
buffer[20] = data.mousePos[0]
buffer[21] = data.mousePos[1]
buffer[22] = data.mouseInfluence
buffer[23] = data.noiseAmount
buffer[24] = data.distortion
buffer[25] = 0
buffer[26] = 0
buffer[27] = 0
return buffer
}
export default function LightRays(props: LightRaysProps) {
let containerRef: HTMLDivElement | undefined
let canvasRef: HTMLCanvasElement | null = null
let deviceRef: GPUDevice | null = null
let contextRef: GPUCanvasContext | null = null
let pipelineRef: GPURenderPipeline | null = null
let uniformBufferRef: GPUBuffer | null = null
let bindGroupRef: GPUBindGroup | null = null
let animationIdRef: number | null = null
let cleanupFunctionRef: (() => void) | null = null
let uniformDataRef: UniformData | null = null
const mouseRef = { x: 0.5, y: 0.5 }
const smoothMouseRef = { x: 0.5, y: 0.5 }
const [isVisible, setIsVisible] = createSignal(false)
onMount(() => {
if (!containerRef) return
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setIsVisible(entry.isIntersecting)
},
{ threshold: 0.1 },
)
observer.observe(containerRef)
onCleanup(() => {
observer.disconnect()
})
})
createEffect(() => {
const visible = isVisible()
const config = props.config()
if (!visible || !containerRef) {
return
}
if (cleanupFunctionRef) {
cleanupFunctionRef()
cleanupFunctionRef = null
}
const initializeWebGPU = async () => {
if (!containerRef) {
return
}
await new Promise((resolve) => setTimeout(resolve, 10))
if (!containerRef) {
return
}
if (!navigator.gpu) {
console.warn("WebGPU is not supported in this browser")
return
}
const adapter = await navigator.gpu.requestAdapter()
if (!adapter) {
console.warn("Failed to get WebGPU adapter")
return
}
const device = await adapter.requestDevice()
deviceRef = device
const canvas = document.createElement("canvas")
canvas.style.width = "100%"
canvas.style.height = "100%"
canvasRef = canvas
while (containerRef.firstChild) {
containerRef.removeChild(containerRef.firstChild)
}
containerRef.appendChild(canvas)
const context = canvas.getContext("webgpu")
if (!context) {
console.warn("Failed to get WebGPU context")
return
}
contextRef = context
const presentationFormat = navigator.gpu.getPreferredCanvasFormat()
context.configure({
device,
format: presentationFormat,
alphaMode: "premultiplied",
})
const shaderModule = device.createShaderModule({
code: WGSL_SHADER,
})
const uniformBuffer = device.createBuffer({
size: UNIFORM_BUFFER_SIZE_CORRECTED,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})
uniformBufferRef = uniformBuffer
const bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: "uniform" },
},
],
})
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: uniformBuffer },
},
],
})
bindGroupRef = bindGroup
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [bindGroupLayout],
})
const pipeline = device.createRenderPipeline({
layout: pipelineLayout,
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
},
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [
{
format: presentationFormat,
blend: {
color: {
srcFactor: "src-alpha",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
alpha: {
srcFactor: "one",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
},
},
],
},
primitive: {
topology: "triangle-list",
},
})
pipelineRef = pipeline
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
const dpr = Math.min(window.devicePixelRatio, 2)
const w = wCSS * dpr
const h = hCSS * dpr
const { anchor, dir } = getAnchorAndDir(config.raysOrigin, w, h)
uniformDataRef = {
iTime: 0,
iResolution: [w, h],
rayPos: anchor,
rayDir: dir,
raysColor: hexToRgb(config.raysColor),
raysSpeed: config.raysSpeed,
lightSpread: config.lightSpread,
rayLength: config.rayLength,
sourceWidth: config.sourceWidth,
pulsating: config.pulsating ? 1.0 : 0.0,
pulsatingMin: config.pulsatingMin,
pulsatingMax: config.pulsatingMax,
fadeDistance: config.fadeDistance,
saturation: config.saturation,
mousePos: [0.5, 0.5],
mouseInfluence: config.mouseInfluence,
noiseAmount: config.noiseAmount,
distortion: config.distortion,
}
const updatePlacement = () => {
if (!containerRef || !canvasRef || !uniformDataRef) {
return
}
const dpr = Math.min(window.devicePixelRatio, 2)
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
const w = Math.floor(wCSS * dpr)
const h = Math.floor(hCSS * dpr)
canvasRef.width = w
canvasRef.height = h
uniformDataRef.iResolution = [w, h]
const currentConfig = props.config()
const { anchor, dir } = getAnchorAndDir(currentConfig.raysOrigin, w, h)
uniformDataRef.rayPos = anchor
uniformDataRef.rayDir = dir
}
const loop = (t: number) => {
if (!deviceRef || !contextRef || !pipelineRef || !uniformBufferRef || !bindGroupRef || !uniformDataRef) {
return
}
const currentConfig = props.config()
const timeSeconds = t * 0.001
uniformDataRef.iTime = timeSeconds
if (currentConfig.followMouse && currentConfig.mouseInfluence > 0.0) {
const smoothing = 0.92
smoothMouseRef.x = smoothMouseRef.x * smoothing + mouseRef.x * (1 - smoothing)
smoothMouseRef.y = smoothMouseRef.y * smoothing + mouseRef.y * (1 - smoothing)
uniformDataRef.mousePos = [smoothMouseRef.x, smoothMouseRef.y]
}
if (props.onAnimationFrame) {
const pulseCenter = (currentConfig.pulsatingMin + currentConfig.pulsatingMax) * 0.5
const pulseAmplitude = (currentConfig.pulsatingMax - currentConfig.pulsatingMin) * 0.5
const pulseValue = currentConfig.pulsating
? pulseCenter + pulseAmplitude * Math.sin(timeSeconds * currentConfig.raysSpeed * 3.0)
: 1.0
const baseIntensity1 = 0.45 + 0.15 * Math.sin(timeSeconds * currentConfig.raysSpeed * 1.5)
const baseIntensity2 = 0.3 + 0.2 * Math.cos(timeSeconds * currentConfig.raysSpeed * 1.1)
const intensity = (baseIntensity1 + baseIntensity2) * pulseValue
props.onAnimationFrame({
time: timeSeconds,
intensity,
pulseValue,
})
}
try {
const uniformData = createUniformBufferCorrected(uniformDataRef)
deviceRef.queue.writeBuffer(uniformBufferRef, 0, uniformData.buffer)
const commandEncoder = deviceRef.createCommandEncoder()
const textureView = contextRef.getCurrentTexture().createView()
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: textureView,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: "clear",
storeOp: "store",
},
],
})
renderPass.setPipeline(pipelineRef)
renderPass.setBindGroup(0, bindGroupRef)
renderPass.draw(3)
renderPass.end()
deviceRef.queue.submit([commandEncoder.finish()])
animationIdRef = requestAnimationFrame(loop)
} catch (error) {
console.warn("WebGPU rendering error:", error)
return
}
}
window.addEventListener("resize", updatePlacement)
updatePlacement()
animationIdRef = requestAnimationFrame(loop)
cleanupFunctionRef = () => {
if (animationIdRef) {
cancelAnimationFrame(animationIdRef)
animationIdRef = null
}
window.removeEventListener("resize", updatePlacement)
if (uniformBufferRef) {
uniformBufferRef.destroy()
uniformBufferRef = null
}
if (deviceRef) {
deviceRef.destroy()
deviceRef = null
}
if (canvasRef && canvasRef.parentNode) {
canvasRef.parentNode.removeChild(canvasRef)
}
canvasRef = null
contextRef = null
pipelineRef = null
bindGroupRef = null
uniformDataRef = null
}
}
initializeWebGPU()
onCleanup(() => {
if (cleanupFunctionRef) {
cleanupFunctionRef()
cleanupFunctionRef = null
}
})
})
createEffect(() => {
if (!uniformDataRef || !containerRef) {
return
}
const config = props.config()
uniformDataRef.raysColor = hexToRgb(config.raysColor)
uniformDataRef.raysSpeed = config.raysSpeed
uniformDataRef.lightSpread = config.lightSpread
uniformDataRef.rayLength = config.rayLength
uniformDataRef.sourceWidth = config.sourceWidth
uniformDataRef.pulsating = config.pulsating ? 1.0 : 0.0
uniformDataRef.pulsatingMin = config.pulsatingMin
uniformDataRef.pulsatingMax = config.pulsatingMax
uniformDataRef.fadeDistance = config.fadeDistance
uniformDataRef.saturation = config.saturation
uniformDataRef.mouseInfluence = config.mouseInfluence
uniformDataRef.noiseAmount = config.noiseAmount
uniformDataRef.distortion = config.distortion
const dpr = Math.min(window.devicePixelRatio, 2)
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
const { anchor, dir } = getAnchorAndDir(config.raysOrigin, wCSS * dpr, hCSS * dpr)
uniformDataRef.rayPos = anchor
uniformDataRef.rayDir = dir
})
createEffect(() => {
const config = props.config()
if (!config.followMouse) {
return
}
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef) {
return
}
const rect = containerRef.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width
const y = (e.clientY - rect.top) / rect.height
mouseRef.x = x
mouseRef.y = y
}
window.addEventListener("mousemove", handleMouseMove)
onCleanup(() => {
window.removeEventListener("mousemove", handleMouseMove)
})
})
return (
<div
ref={containerRef}
class={`light-rays-container ${props.class ?? ""}`.trim()}
style={{ opacity: props.config().opacity }}
/>
)
}
interface LightRaysControlsProps {
config: Accessor<LightRaysConfig>
setConfig: Setter<LightRaysConfig>
}
export function LightRaysControls(props: LightRaysControlsProps) {
const [isOpen, setIsOpen] = createSignal(true)
const updateConfig = <K extends keyof LightRaysConfig>(key: K, value: LightRaysConfig[K]) => {
props.setConfig((prev) => ({ ...prev, [key]: value }))
}
const origins: RaysOrigin[] = [
"top-center",
"top-left",
"top-right",
"left",
"right",
"bottom-center",
"bottom-left",
"bottom-right",
]
return (
<div class="light-rays-controls">
<button class="light-rays-controls-toggle" onClick={() => setIsOpen(!isOpen())}>
{isOpen() ? "▼" : "▶"} Light Rays
</button>
<Show when={isOpen()}>
<div class="light-rays-controls-panel">
<div class="control-group">
<label>Origin</label>
<select
value={props.config().raysOrigin}
onChange={(e) => updateConfig("raysOrigin", e.currentTarget.value as RaysOrigin)}
>
<For each={origins}>{(origin) => <option value={origin}>{origin}</option>}</For>
</select>
</div>
<div class="control-group">
<label>Color</label>
<input
type="color"
value={props.config().raysColor}
onInput={(e) => updateConfig("raysColor", e.currentTarget.value)}
/>
</div>
<div class="control-group">
<label>Speed: {props.config().raysSpeed.toFixed(2)}</label>
<input
type="range"
min="0"
max="3"
step="0.01"
value={props.config().raysSpeed}
onInput={(e) => updateConfig("raysSpeed", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group">
<label>Light Spread: {props.config().lightSpread.toFixed(2)}</label>
<input
type="range"
min="0.1"
max="5"
step="0.01"
value={props.config().lightSpread}
onInput={(e) => updateConfig("lightSpread", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group">
<label>Ray Length: {props.config().rayLength.toFixed(2)}</label>
<input
type="range"
min="0.1"
max="5"
step="0.01"
value={props.config().rayLength}
onInput={(e) => updateConfig("rayLength", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group">
<label>Source Width: {props.config().sourceWidth.toFixed(2)}</label>
<input
type="range"
min="0"
max="2"
step="0.01"
value={props.config().sourceWidth}
onInput={(e) => updateConfig("sourceWidth", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group">
<label>Fade Distance: {props.config().fadeDistance.toFixed(2)}</label>
<input
type="range"
min="0.1"
max="3"
step="0.01"
value={props.config().fadeDistance}
onInput={(e) => updateConfig("fadeDistance", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group">
<label>Saturation: {props.config().saturation.toFixed(2)}</label>
<input
type="range"
min="0"
max="2"
step="0.01"
value={props.config().saturation}
onInput={(e) => updateConfig("saturation", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group">
<label>Mouse Influence: {props.config().mouseInfluence.toFixed(2)}</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={props.config().mouseInfluence}
onInput={(e) => updateConfig("mouseInfluence", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group">
<label>Noise: {props.config().noiseAmount.toFixed(2)}</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={props.config().noiseAmount}
onInput={(e) => updateConfig("noiseAmount", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group">
<label>Distortion: {props.config().distortion.toFixed(2)}</label>
<input
type="range"
min="0"
max="2"
step="0.01"
value={props.config().distortion}
onInput={(e) => updateConfig("distortion", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group">
<label>Opacity: {props.config().opacity.toFixed(2)}</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={props.config().opacity}
onInput={(e) => updateConfig("opacity", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group checkbox">
<label>
<input
type="checkbox"
checked={props.config().pulsating}
onChange={(e) => updateConfig("pulsating", e.currentTarget.checked)}
/>
Pulsating
</label>
</div>
<Show when={props.config().pulsating}>
<div class="control-group">
<label>Pulse Min: {props.config().pulsatingMin.toFixed(2)}</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={props.config().pulsatingMin}
onInput={(e) => updateConfig("pulsatingMin", parseFloat(e.currentTarget.value))}
/>
</div>
<div class="control-group">
<label>Pulse Max: {props.config().pulsatingMax.toFixed(2)}</label>
<input
type="range"
min="0"
max="2"
step="0.01"
value={props.config().pulsatingMax}
onInput={(e) => updateConfig("pulsatingMax", parseFloat(e.currentTarget.value))}
/>
</div>
</Show>
<div class="control-group checkbox">
<label>
<input
type="checkbox"
checked={props.config().followMouse}
onChange={(e) => updateConfig("followMouse", e.currentTarget.checked)}
/>
Follow Mouse
</label>
</div>
<button class="reset-button" onClick={() => props.setConfig(defaultConfig)}>
Reset to Defaults
</button>
</div>
</Show>
</div>
)
}

View File

@@ -14,13 +14,14 @@ export const github = query(async () => {
fetch(`${apiBaseUrl}/releases`, { headers }).then((res) => res.json()),
fetch(`${apiBaseUrl}/contributors?per_page=1`, { headers }),
])
if (!Array.isArray(releases) || releases.length === 0) {
return undefined
}
const [release] = releases
const contributorCount = Number.parseInt(
contributors.headers
.get("Link")!
.match(/&page=(\d+)>; rel="last"/)!
.at(1)!,
)
const linkHeader = contributors.headers.get("Link")
const contributorCount = linkHeader
? Number.parseInt(linkHeader.match(/&page=(\d+)>; rel="last"/)?.at(1) ?? "0")
: 0
return {
stars: meta.stargazers_count,
release: {

View File

@@ -1,3 +1,133 @@
::view-transition-group(*) {
animation-duration: 250ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 250ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-image-pair(root) {
isolation: isolate;
}
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes reveal-terms {
from {
mask-position: 0% 200%;
}
to {
mask-position: 0% 50%;
}
}
@keyframes hide-terms {
from {
mask-position: 0% 50%;
}
to {
mask-position: 0% 200%;
}
}
::view-transition-old(terms-20),
::view-transition-old(terms-100),
::view-transition-old(terms-200) {
mask-image: linear-gradient(to bottom, transparent, black 25% 75%, transparent);
mask-repeat: no-repeat;
mask-size: 100% 200%;
animation: hide-terms 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
}
::view-transition-new(terms-20),
::view-transition-new(terms-100),
::view-transition-new(terms-200) {
mask-image: linear-gradient(to bottom, transparent, black 25% 75%, transparent);
mask-repeat: no-repeat;
mask-position: 0% 200%;
mask-size: 100% 200%;
animation: reveal-terms 300ms cubic-bezier(0.25, 0, 0.5, 1) 50ms forwards;
}
::view-transition-old(actions-20),
::view-transition-old(actions-100),
::view-transition-old(actions-200) {
animation: fade-out 80ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
::view-transition-new(actions-20),
::view-transition-new(actions-100),
::view-transition-new(actions-200) {
animation: fade-in-up 200ms cubic-bezier(0.16, 1, 0.3, 1) 300ms forwards;
opacity: 0;
}
::view-transition-group(card-20),
::view-transition-group(card-100),
::view-transition-group(card-200) {
animation-duration: 250ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-image-pair(card-20),
::view-transition-image-pair(card-100),
::view-transition-image-pair(card-200) {
isolation: isolate;
overflow: hidden;
}
::view-transition-old(card-20),
::view-transition-old(card-100),
::view-transition-old(card-200) {
mix-blend-mode: normal;
}
::view-transition-new(card-20),
::view-transition-new(card-100),
::view-transition-new(card-200) {
mix-blend-mode: normal;
}
[data-page="black"] {
background: #000;
min-height: 100vh;
@@ -8,13 +138,18 @@
font-family: var(--font-mono);
color: #fff;
[data-component="header-gradient"] {
[data-component="header-logo"] {
filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.25)) drop-shadow(0 4px 16px rgba(0, 0, 0, 0.1));
position: relative;
z-index: 1;
}
.header-light-rays {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 288px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
inset: 0 0 auto 0;
height: 30dvh;
pointer-events: none;
z-index: 0;
}
[data-component="header"] {
@@ -24,9 +159,6 @@
justify-content: center;
padding-top: 40px;
flex-shrink: 0;
/* [data-component="header-logo"] { */
/* } */
}
[data-component="content"] {
@@ -51,27 +183,35 @@
h1 {
color: rgba(255, 255, 255, 0.92);
font-size: 18px;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
line-height: 1.45;
margin: 0;
@media (min-width: 768px) {
font-size: 24px;
font-size: 20px;
}
@media (max-width: 480px) {
font-size: 14px;
}
}
p {
color: rgba(255, 255, 255, 0.59);
font-size: 15px;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
line-height: 1.45;
margin: 0;
@media (min-width: 768px) {
font-size: 18px;
font-size: 20px;
}
@media (max-width: 480px) {
font-size: 14px;
}
}
}
@@ -79,6 +219,7 @@
[data-slot="hero-black"] {
margin-top: 40px;
padding: 0 20px;
position: relative;
@media (min-width: 768px) {
margin-top: 60px;
@@ -86,9 +227,29 @@
svg {
width: 100%;
max-width: 540px;
max-width: 590px;
height: auto;
filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.1));
overflow: visible;
filter: drop-shadow(0 0 20px rgba(255, 255, 255, calc(0.1 + var(--hero-black-glow-intensity, 0) * 0.15)))
drop-shadow(0 -5px 30px rgba(255, 255, 255, calc(var(--hero-black-glow-intensity, 0) * 0.2)));
mask-image: linear-gradient(to bottom, black, transparent);
stroke-width: 1.5;
[data-slot="black-base"] {
fill: url(#hero-black-fill-gradient);
stroke: url(#hero-black-stroke-gradient);
}
[data-slot="black-glow"] {
fill: url(#hero-black-top-glow);
pointer-events: none;
}
[data-slot="black-shimmer"] {
fill: url(#hero-black-shimmer-gradient);
pointer-events: none;
mix-blend-mode: overlay;
}
}
}
@@ -111,7 +272,7 @@
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 28.8px */
line-height: 160%;
span {
display: inline-block;
@@ -172,7 +333,7 @@
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
font-family: "JetBrains Mono Nerd Font";
font-family: var(--font-mono);
font-size: 14px;
font-style: normal;
font-weight: 400;
@@ -185,7 +346,7 @@
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 540px;
max-width: 660px;
padding: 0 20px;
@media (min-width: 768px) {
@@ -197,16 +358,21 @@
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.17);
background: black;
background-clip: padding-box;
border-radius: 4px;
text-decoration: none;
transition: border-color 0.15s ease;
background: transparent;
cursor: pointer;
text-align: left;
&:hover {
@media (max-width: 480px) {
padding: 16px;
}
&:hover:not(:active) {
border-color: rgba(255, 255, 255, 0.35);
}
@@ -247,19 +413,27 @@
display: flex;
flex-direction: column;
gap: 32px;
width: fit-content;
max-width: calc(100% - 40px);
width: 100%;
max-width: 660px;
margin: 0 auto;
position: relative;
background-color: rgba(0, 0, 0, 0.75);
z-index: 1;
@media (max-width: 480px) {
margin: 0 20px;
width: calc(100% - 40px);
}
}
[data-slot="selected-card"] {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
gap: 12px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
width: fit-content;
width: 100%;
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
@@ -299,16 +473,15 @@
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
text-align: left;
li {
color: rgba(255, 255, 255, 0.59);
font-size: 13px;
font-size: 14px;
line-height: 1.5;
padding-left: 16px;
position: relative;
white-space: nowrap;
&::before {
content: "▪";
@@ -316,6 +489,10 @@
left: 0;
color: rgba(255, 255, 255, 0.39);
}
@media (max-width: 768px) {
font-size: 12px;
}
}
}
@@ -341,22 +518,26 @@
}
[data-slot="cancel"] {
background: transparent;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.92);
transition-property: background-color, border-color;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
&:hover {
border-color: rgba(255, 255, 255, 0.35);
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.25);
}
}
[data-slot="continue"] {
background: rgba(255, 255, 255, 0.17);
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
background: rgb(255, 255, 255);
color: rgb(0, 0, 0);
transition: background-color 150ms cubic-bezier(0.25, 0, 0.5, 1);
&:hover {
background: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.9);
}
}
}
@@ -386,7 +567,7 @@
align-items: center;
margin-top: -18px;
width: 100%;
max-width: 540px;
max-width: 660px;
padding: 0 20px;
@media (min-width: 768px) {
@@ -460,39 +641,6 @@
font-weight: 400;
}
[data-slot="tax-id-section"] {
display: flex;
flex-direction: column;
gap: 8px;
[data-slot="label"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="input"] {
width: 100%;
height: 44px;
padding: 0 12px;
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
color: #ffffff;
font-family: var(--font-mono);
font-size: 14px;
outline: none;
transition: border-color 0.15s ease;
&::placeholder {
color: rgba(255, 255, 255, 0.39);
}
&:focus {
border-color: rgba(255, 255, 255, 0.35);
}
}
}
[data-slot="checkout-form"] {
display: flex;
flex-direction: column;
@@ -533,52 +681,6 @@
text-align: center;
}
[data-slot="success"] {
display: flex;
flex-direction: column;
gap: 24px;
[data-slot="title"] {
color: rgba(255, 255, 255, 0.92);
font-size: 18px;
font-weight: 400;
margin: 0;
}
[data-slot="details"] {
display: flex;
flex-direction: column;
gap: 16px;
> div {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
}
dt {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
font-weight: 400;
}
dd {
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 400;
margin: 0;
text-align: right;
}
}
[data-slot="charge-notice"] {
color: #d4a500;
font-size: 14px;
text-align: left;
}
}
[data-slot="loading"] {
display: flex;
justify-content: center;
@@ -595,6 +697,7 @@
text-align: center;
font-size: 13px;
font-style: italic;
view-transition-name: fine-print;
a {
color: rgba(255, 255, 255, 0.39);

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,8 @@ import { Match, Switch } from "solid-js"
export const plans = [
{ id: "20", multiplier: null },
{ id: "100", multiplier: "6x more usage than Black 20" },
{ id: "200", multiplier: "21x more usage than Black 20" },
{ id: "100", multiplier: "5x more usage than Black 20" },
{ id: "200", multiplier: "20x more usage than Black 20" },
] as const
export type PlanID = (typeof plans)[number]["id"]
@@ -14,28 +14,47 @@ export function PlanIcon(props: { plan: string }) {
<Switch>
<Match when={props.plan === "20"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="1.5" />
<title>Black 20 plan</title>
<rect x="0.5" y="0.5" width="23" height="23" stroke="currentColor" />
</svg>
</Match>
<Match when={props.plan === "100"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<title>Black 100 plan</title>
<rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" />
<rect x="0.5" y="14.5" width="9" height="9" stroke="currentColor" />
<rect x="14.5" y="0.5" width="9" height="9" stroke="currentColor" />
<rect x="14.5" y="14.5" width="9" height="9" stroke="currentColor" />
</svg>
</Match>
<Match when={props.plan === "200"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="10" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="18" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="2" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="10" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="18" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="2" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="10" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="18" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<title>Black 200 plan</title>
<rect x="0.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="20.5" width="3" height="3" stroke="currentColor" />
</svg>
</Match>
</Switch>

View File

@@ -1,13 +1,39 @@
import { A, useSearchParams } from "@solidjs/router"
import { Title } from "@solidjs/meta"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js"
import { PlanIcon, plans } from "./common"
export default function Black() {
const [params] = useSearchParams()
const [selected, setSelected] = createSignal<string | null>((params.plan as string) || null)
const [mounted, setMounted] = createSignal(false)
const selectedPlan = createMemo(() => plans.find((p) => p.id === selected()))
onMount(() => {
requestAnimationFrame(() => setMounted(true))
})
const transition = (action: () => void) => {
if (mounted() && "startViewTransition" in document) {
;(document as any).startViewTransition(action)
return
}
action()
}
const select = (planId: string) => {
if (selected() === planId) {
return
}
transition(() => setSelected(planId))
}
const cancel = () => {
transition(() => setSelected(null))
}
return (
<>
<Title>opencode</Title>
@@ -17,11 +43,16 @@ export default function Black() {
<div data-slot="pricing">
<For each={plans}>
{(plan) => (
<button type="button" onClick={() => setSelected(plan.id)} data-slot="pricing-card">
<div data-slot="icon">
<button
type="button"
onClick={() => select(plan.id)}
data-slot="pricing-card"
style={{ "view-transition-name": `card-${plan.id}` }}
>
<div data-slot="icon" style={{ "view-transition-name": `icon-${plan.id}` }}>
<PlanIcon plan={plan.id} />
</div>
<p data-slot="price">
<p data-slot="price" style={{ "view-transition-name": `price-${plan.id}` }}>
<span data-slot="amount">${plan.id}</span> <span data-slot="period">per month</span>
<Show when={plan.multiplier}>
<span data-slot="multiplier">{plan.multiplier}</span>
@@ -31,25 +62,22 @@ export default function Black() {
)}
</For>
</div>
<p data-slot="fine-print">
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
</p>
</Match>
<Match when={selectedPlan()}>
{(plan) => (
<div data-slot="selected-plan">
<div data-slot="selected-card">
<div data-slot="icon">
<div data-slot="selected-card" style={{ "view-transition-name": `card-${plan().id}` }}>
<div data-slot="icon" style={{ "view-transition-name": `icon-${plan().id}` }}>
<PlanIcon plan={plan().id} />
</div>
<p data-slot="price">
<p data-slot="price" style={{ "view-transition-name": `price-${plan().id}` }}>
<span data-slot="amount">${plan().id}</span>{" "}
<span data-slot="period">per person billed monthly</span>
<Show when={plan().multiplier}>
<span data-slot="multiplier">{plan().multiplier}</span>
</Show>
</p>
<ul data-slot="terms">
<ul data-slot="terms" style={{ "view-transition-name": `terms-${plan().id}` }}>
<li>Your subscription will not start immediately</li>
<li>You will be added to the waitlist and activated soon</li>
<li>Your card will be only charged when your subscription is activated</li>
@@ -58,8 +86,8 @@ export default function Black() {
<li>Limits may be adjusted and plans may be discontinued in the future</li>
<li>Cancel your subscription at anytime</li>
</ul>
<div data-slot="actions">
<button type="button" onClick={() => setSelected(null)} data-slot="cancel">
<div data-slot="actions" style={{ "view-transition-name": `actions-${plan().id}` }}>
<button type="button" onClick={() => cancel()} data-slot="cancel">
Cancel
</button>
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
@@ -67,13 +95,13 @@ export default function Black() {
</a>
</div>
</div>
<p data-slot="fine-print">
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
</p>
</div>
)}
</Match>
</Switch>
<p data-slot="fine-print" style={{ "view-transition-name": "fine-print" }}>
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
</p>
</section>
</>
)

View File

@@ -18,10 +18,10 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
const getWorkspaces = query(async () => {
const getWorkspaces = query(async (plan: string) => {
"use server"
const actor = await getActor()
if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe")
if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe/" + plan)
return withActor(async () => {
return Database.use((tx) =>
tx
@@ -258,15 +258,16 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data
}
export default function BlackSubscribe() {
const workspaces = createAsync(() => getWorkspaces())
const params = useParams()
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
const plan = planData.id
const workspaces = createAsync(() => getWorkspaces(plan))
const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined)
const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined)
const [failure, setFailure] = createSignal<string | undefined>(undefined)
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
const params = useParams()
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
const plan = planData.id
// Resolve stripe promise once
createEffect(() => {

View File

@@ -0,0 +1,477 @@
::selection {
background: var(--color-background-interactive);
color: var(--color-text-strong);
@media (prefers-color-scheme: dark) {
background: var(--color-background-interactive);
color: var(--color-text-inverted);
}
}
[data-page="changelog"] {
--color-background: hsl(0, 20%, 99%);
--color-background-weak: hsl(0, 8%, 97%);
--color-background-weak-hover: hsl(0, 8%, 94%);
--color-background-strong: hsl(0, 5%, 12%);
--color-background-strong-hover: hsl(0, 5%, 18%);
--color-background-interactive: hsl(62, 84%, 88%);
--color-background-interactive-weaker: hsl(64, 74%, 95%);
--color-text: hsl(0, 1%, 39%);
--color-text-weak: hsl(0, 1%, 60%);
--color-text-weaker: hsl(30, 2%, 81%);
--color-text-strong: hsl(0, 5%, 12%);
--color-text-inverted: hsl(0, 20%, 99%);
--color-border: hsl(30, 2%, 81%);
--color-border-weak: hsl(0, 1%, 85%);
--color-icon: hsl(0, 1%, 55%);
background: var(--color-background);
font-family: var(--font-mono);
color: var(--color-text);
padding-bottom: 5rem;
@media (prefers-color-scheme: dark) {
--color-background: hsl(0, 9%, 7%);
--color-background-weak: hsl(0, 6%, 10%);
--color-background-weak-hover: hsl(0, 6%, 15%);
--color-background-strong: hsl(0, 15%, 94%);
--color-background-strong-hover: hsl(0, 15%, 97%);
--color-background-interactive: hsl(62, 100%, 90%);
--color-background-interactive-weaker: hsl(60, 20%, 8%);
--color-text: hsl(0, 4%, 71%);
--color-text-weak: hsl(0, 2%, 49%);
--color-text-weaker: hsl(0, 3%, 28%);
--color-text-strong: hsl(0, 15%, 94%);
--color-text-inverted: hsl(0, 9%, 7%);
--color-border: hsl(0, 3%, 28%);
--color-border-weak: hsl(0, 4%, 23%);
--color-icon: hsl(10, 3%, 43%);
}
/* Header styles - copied from download */
[data-component="top"] {
padding: 24px 5rem;
height: 80px;
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-background);
border-bottom: 1px solid var(--color-border-weak);
z-index: 10;
@media (max-width: 60rem) {
padding: 24px 1.5rem;
}
img {
height: 34px;
width: auto;
}
[data-component="nav-desktop"] {
ul {
display: flex;
justify-content: space-between;
align-items: center;
gap: 48px;
@media (max-width: 55rem) {
gap: 32px;
}
@media (max-width: 48rem) {
gap: 24px;
}
li {
display: inline-block;
a {
text-decoration: none;
span {
color: var(--color-text-weak);
}
}
a:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
[data-slot="cta-button"] {
background: var(--color-background-strong);
color: var(--color-text-inverted);
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
text-decoration: none;
@media (max-width: 55rem) {
display: none;
}
}
[data-slot="cta-button"]:hover {
background: var(--color-background-strong-hover);
text-decoration: none;
}
}
}
@media (max-width: 40rem) {
display: none;
}
}
[data-component="nav-mobile"] {
button > svg {
color: var(--color-icon);
}
}
[data-component="nav-mobile-toggle"] {
border: none;
background: none;
outline: none;
height: 40px;
width: 40px;
cursor: pointer;
margin-right: -8px;
}
[data-component="nav-mobile-toggle"]:hover {
background: var(--color-background-weak);
}
[data-component="nav-mobile"] {
display: none;
@media (max-width: 40rem) {
display: block;
[data-component="nav-mobile-icon"] {
cursor: pointer;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
[data-component="nav-mobile-menu-list"] {
position: fixed;
background: var(--color-background);
top: 80px;
left: 0;
right: 0;
height: 100vh;
ul {
list-style: none;
padding: 20px 0;
li {
a {
text-decoration: none;
padding: 20px;
display: block;
span {
color: var(--color-text-weak);
}
}
a:hover {
background: var(--color-background-weak);
}
}
}
}
}
}
[data-slot="logo dark"] {
display: none;
}
@media (prefers-color-scheme: dark) {
[data-slot="logo light"] {
display: none;
}
[data-slot="logo dark"] {
display: block;
}
}
}
[data-component="footer"] {
border-top: 1px solid var(--color-border-weak);
display: flex;
flex-direction: row;
margin-top: 4rem;
@media (max-width: 65rem) {
border-bottom: 1px solid var(--color-border-weak);
}
[data-slot="cell"] {
flex: 1;
text-align: center;
a {
text-decoration: none;
padding: 2rem 0;
width: 100%;
display: block;
span {
color: var(--color-text-weak);
@media (max-width: 40rem) {
display: none;
}
}
}
a:hover {
background: var(--color-background-weak);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
[data-slot="cell"] + [data-slot="cell"] {
border-left: 1px solid var(--color-border-weak);
@media (max-width: 40rem) {
border-left: none;
}
}
@media (max-width: 25rem) {
flex-wrap: wrap;
[data-slot="cell"] {
flex: 1 0 100%;
border-left: none;
border-top: 1px solid var(--color-border-weak);
}
[data-slot="cell"]:nth-child(1) {
border-top: none;
}
}
}
[data-component="container"] {
max-width: 67.5rem;
margin: 0 auto;
border: 1px solid var(--color-border-weak);
border-top: none;
@media (max-width: 65rem) {
border: none;
}
}
[data-component="content"] {
padding: 6rem 5rem;
@media (max-width: 60rem) {
padding: 4rem 1.5rem;
}
}
[data-component="legal"] {
color: var(--color-text-weak);
text-align: center;
padding: 2rem 5rem;
display: flex;
gap: 32px;
justify-content: center;
@media (max-width: 60rem) {
padding: 2rem 1.5rem;
}
a {
color: var(--color-text-weak);
text-decoration: none;
}
a:hover {
color: var(--color-text);
text-decoration: underline;
}
}
/* Changelog Hero */
[data-component="changelog-hero"] {
margin-bottom: 4rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--color-border-weak);
@media (max-width: 50rem) {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text-strong);
margin-bottom: 8px;
}
p {
color: var(--color-text);
}
}
/* Releases */
[data-component="releases"] {
display: flex;
flex-direction: column;
gap: 0;
}
[data-component="release"] {
display: grid;
grid-template-columns: 180px 1fr;
gap: 3rem;
padding: 2rem 0;
border-bottom: 1px solid var(--color-border-weak);
@media (max-width: 50rem) {
grid-template-columns: 1fr;
gap: 1rem;
}
&:first-child {
padding-top: 0;
}
&:last-child {
border-bottom: none;
}
header {
display: flex;
flex-direction: column;
gap: 4px;
@media (max-width: 50rem) {
flex-direction: row;
align-items: center;
gap: 12px;
}
[data-slot="version"] {
a {
font-weight: 600;
color: var(--color-text-strong);
text-decoration: none;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
time {
color: var(--color-text-weak);
font-size: 14px;
}
}
[data-slot="content"] {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
[data-component="section"] {
h3 {
font-size: 14px;
font-weight: 600;
color: var(--color-text-strong);
margin-bottom: 8px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
li {
color: var(--color-text);
line-height: 1.5;
padding-left: 16px;
position: relative;
&::before {
content: "-";
position: absolute;
left: 0;
color: var(--color-text-weak);
}
[data-slot="author"] {
color: var(--color-text-weak);
font-size: 13px;
margin-left: 4px;
text-decoration: none;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
}
}
[data-component="contributors"] {
font-size: 13px;
color: var(--color-text-weak);
padding-top: 0.5rem;
span {
color: var(--color-text-weak);
}
a {
color: var(--color-text);
text-decoration: none;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
}
a {
color: var(--color-text-strong);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
&:hover {
text-decoration-thickness: 2px;
}
}
}

View File

@@ -0,0 +1,147 @@
import "./index.css"
import { Title, Meta, Link } from "@solidjs/meta"
import { createAsync, query } from "@solidjs/router"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { config } from "~/config"
import { For, Show } from "solid-js"
type Release = {
tag_name: string
name: string
body: string
published_at: string
html_url: string
}
const getReleases = query(async () => {
"use server"
const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenCode-Console",
},
cf: {
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any)
if (!response.ok) return []
return response.json() as Promise<Release[]>
}, "releases.get")
function formatDate(dateString: string) {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
}
function parseMarkdown(body: string) {
const lines = body.split("\n")
const sections: { title: string; items: string[] }[] = []
let current: { title: string; items: string[] } | null = null
let skip = false
for (const line of lines) {
if (line.startsWith("## ")) {
if (current) sections.push(current)
const title = line.slice(3).trim()
current = { title, items: [] }
skip = false
} else if (line.startsWith("**Thank you")) {
skip = true
} else if (line.startsWith("- ") && !skip) {
current?.items.push(line.slice(2).trim())
}
}
if (current) sections.push(current)
return { sections }
}
function ReleaseItem(props: { item: string }) {
const parts = () => {
const match = props.item.match(/^(.+?)(\s*\(@([\w-]+)\))?$/)
if (match) {
return {
text: match[1],
username: match[3],
}
}
return { text: props.item, username: undefined }
}
return (
<li>
<span>{parts().text}</span>
<Show when={parts().username}>
<a data-slot="author" href={`https://github.com/${parts().username}`} target="_blank" rel="noopener noreferrer">
(@{parts().username})
</a>
</Show>
</li>
)
}
export default function Changelog() {
const releases = createAsync(() => getReleases())
return (
<main data-page="changelog">
<Title>OpenCode | Changelog</Title>
<Link rel="canonical" href={`${config.baseUrl}/changelog`} />
<Meta name="description" content="OpenCode release notes and changelog" />
<div data-component="container">
<Header hideGetStarted />
<div data-component="content">
<section data-component="changelog-hero">
<h1>Changelog</h1>
<p>New updates and improvements to OpenCode</p>
</section>
<section data-component="releases">
<For each={releases()}>
{(release) => {
const parsed = () => parseMarkdown(release.body || "")
return (
<article data-component="release">
<header>
<div data-slot="version">
<a href={release.html_url} target="_blank" rel="noopener noreferrer">
{release.tag_name}
</a>
</div>
<time dateTime={release.published_at}>{formatDate(release.published_at)}</time>
</header>
<div data-slot="content">
<For each={parsed().sections}>
{(section) => (
<div data-component="section">
<h3>{section.title}</h3>
<ul>
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
</ul>
</div>
)}
</For>
</div>
</article>
)
}}
</For>
</section>
<Footer />
</div>
</div>
<Legal />
</main>
)
}

View File

@@ -34,7 +34,6 @@
font-family: var(--font-mono);
color: var(--color-text);
padding-bottom: 5rem;
overflow-x: hidden;
@media (prefers-color-scheme: dark) {
--color-background: hsl(0, 9%, 7%);

View File

@@ -441,7 +441,8 @@ export default function Download() {
</li>
<li>
<Faq question="Can I only use OpenCode in the terminal?">
Not anymore! OpenCode is now available as an app for your desktop.
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
<a href="/docs/cli/#web">web</a>!
</Faq>
</li>
<li>

View File

@@ -692,7 +692,8 @@ export default function Home() {
</li>
<li>
<Faq question="Can I only use OpenCode in the terminal?">
Not anymore! OpenCode is now available as an app for your desktop.
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
<a href="/docs/web">web</a>!
</Faq>
</li>
<li>

View File

@@ -5,4 +5,58 @@
align-items: center;
gap: var(--space-4);
}
[data-slot="usage"] {
display: flex;
gap: var(--space-6);
margin-top: var(--space-4);
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-4);
}
}
[data-slot="usage-item"] {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="usage-header"] {
display: flex;
justify-content: space-between;
align-items: baseline;
}
[data-slot="usage-label"] {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--color-text);
}
[data-slot="usage-value"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
[data-slot="progress"] {
height: 8px;
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
[data-slot="progress-bar"] {
height: 100%;
background-color: var(--color-accent);
border-radius: var(--border-radius-sm);
transition: width 0.3s ease;
}
[data-slot="reset-time"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}

View File

@@ -1,10 +1,58 @@
import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { Show } from "solid-js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Black } from "@opencode-ai/console-core/black.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
const querySubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const row = await Database.use((tx) =>
tx
.select({
rollingUsage: SubscriptionTable.rollingUsage,
fixedUsage: SubscriptionTable.fixedUsage,
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
})
.from(SubscriptionTable)
.where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted)))
.then((r) => r[0]),
)
if (!row) return null
return {
rollingUsage: Black.analyzeRollingUsage({
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
weeklyUsage: Black.analyzeWeeklyUsage({
usage: row.fixedUsage ?? 0,
timeUpdated: row.timeFixedUpdated ?? new Date(),
}),
}
}, workspaceID)
}, "subscription.get")
function formatResetTime(seconds: number) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? "day" : "days"} ${hours} ${hours === 1 ? "hour" : "hours"}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1) return `${hours} ${hours === 1 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}`
if (minutes === 0) return "a few seconds"
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
}
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
@@ -26,6 +74,7 @@ export function BlackSection() {
const params = useParams()
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const subscription = createAsync(() => querySubscription(params.id!))
const [store, setStore] = createStore({
sessionRedirecting: false,
})
@@ -53,6 +102,32 @@ export function BlackSection() {
</button>
</div>
</div>
<Show when={subscription()}>
{(sub) => (
<div data-slot="usage">
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">5-hour Usage</span>
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">Resets in {formatResetTime(sub().rollingUsage.resetInSec)}</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">Weekly Usage</span>
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
</div>
</div>
)}
</Show>
</section>
)
}

View File

@@ -3,7 +3,6 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
import { action, json, 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"
@@ -96,11 +95,22 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
return withActor(async () => {
const billing = await Billing.get()
return {
...billing,
customerID: billing.customerID,
paymentMethodID: billing.paymentMethodID,
paymentMethodType: billing.paymentMethodType,
paymentMethodLast4: billing.paymentMethodLast4,
balance: billing.balance,
reload: billing.reload,
reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
monthlyLimit: billing.monthlyLimit,
monthlyUsage: billing.monthlyUsage,
timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
reloadError: billing.reloadError,
timeReloadError: billing.timeReloadError,
subscriptionID: billing.subscriptionID,
}
}, workspaceID)
}, "billing.get")

View File

@@ -9,7 +9,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ZenData } from "@opencode-ai/console-core/model.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { Black, BlackData } from "@opencode-ai/console-core/black.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
@@ -495,27 +495,28 @@ export async function handler(
// Check weekly limit
if (sub.fixedUsage && sub.timeFixedUpdated) {
const week = getWeekBounds(now)
if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
const result = Black.analyzeWeeklyUsage({
usage: sub.fixedUsage,
timeUpdated: sub.timeFixedUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter,
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
}
}
// Check rolling limit
if (sub.rollingUsage && sub.timeRollingUpdated) {
const rollingWindowMs = black.rollingWindow * 3600 * 1000
const windowStart = new Date(now.getTime() - rollingWindowMs)
if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
const result = Black.analyzeRollingUsage({
usage: sub.rollingUsage,
timeUpdated: sub.timeRollingUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter,
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
}
}
return

View File

@@ -12,7 +12,7 @@
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vite/client"],
"types": ["vite/client", "@webgpu/types"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.19",
"version": "1.1.21",
"private": true,
"type": "module",
"license": "MIT",
@@ -32,6 +32,7 @@
"promote-models-to-dev": "script/promote-models.ts dev",
"promote-models-to-prod": "script/promote-models.ts production",
"pull-models-from-dev": "script/pull-models.ts dev",
"pull-models-from-prod": "script/pull-models.ts production",
"update-black": "script/update-black.ts",
"promote-black-to-dev": "script/promote-black.ts dev",
"promote-black-to-prod": "script/promote-black.ts production",

View File

@@ -0,0 +1,41 @@
import { subscribe } from "diagnostics_channel"
import { Billing } from "../src/billing.js"
import { and, Database, eq } from "../src/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
const workspaceID = process.argv[2]
if (!workspaceID) {
console.error("Usage: bun script/foo.ts <workspaceID>")
process.exit(1)
}
console.log(`Removing from Black waitlist`)
const billing = await Database.use((tx) =>
tx
.select({
subscriptionPlan: BillingTable.subscriptionPlan,
timeSubscriptionBooked: BillingTable.timeSubscriptionBooked,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (!billing?.timeSubscriptionBooked) {
console.error(`Error: Workspace is not on the waitlist`)
process.exit(1)
}
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
subscriptionPlan: null,
timeSubscriptionBooked: null,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
console.log(`Done`)

View File

@@ -1,4 +1,6 @@
import { Billing } from "../src/billing.js"
import { Database, eq } from "../src/drizzle/index.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
// get input from command line
const workspaceID = process.argv[2]
@@ -9,6 +11,19 @@ if (!workspaceID || !dollarAmount) {
process.exit(1)
}
// check workspace exists
const workspace = await Database.use((tx) =>
tx
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0]),
)
if (!workspace) {
console.error("Error: Workspace not found")
process.exit(1)
}
const amountInDollars = parseFloat(dollarAmount)
if (isNaN(amountInDollars) || amountInDollars <= 0) {
console.error("Error: dollarAmount must be a positive number")

View File

@@ -113,8 +113,13 @@ async function printWorkspace(workspaceID: string) {
.select({
balance: BillingTable.balance,
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
subscriptionCouponID: BillingTable.subscriptionCouponID,
reload: BillingTable.reload,
subscription: {
id: BillingTable.subscriptionID,
couponID: BillingTable.subscriptionCouponID,
plan: BillingTable.subscriptionPlan,
booked: BillingTable.timeSubscriptionBooked,
},
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id))
@@ -123,6 +128,11 @@ async function printWorkspace(workspaceID: string) {
rows.map((row) => ({
...row,
balance: `$${(row.balance / 100000000).toFixed(2)}`,
subscription: row.subscription.id
? `Subscribed ${row.subscription.couponID ? `(coupon: ${row.subscription.couponID}) ` : ""}`
: row.subscription.booked
? `Waitlist ${row.subscription.plan} plan`
: undefined,
}))[0],
),
)

View File

@@ -25,22 +25,7 @@ export namespace Billing {
export const get = async () => {
return Database.use(async (tx) =>
tx
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4,
balance: BillingTable.balance,
reload: BillingTable.reload,
reloadAmount: BillingTable.reloadAmount,
reloadTrigger: BillingTable.reloadTrigger,
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
reloadError: BillingTable.reloadError,
timeReloadError: BillingTable.timeReloadError,
})
.select()
.from(BillingTable)
.where(eq(BillingTable.workspaceID, Actor.workspace()))
.then((r) => r[0]),

View File

@@ -1,6 +1,8 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { Resource } from "@opencode-ai/console-resource"
import { centsToMicroCents } from "./util/price"
import { getWeekBounds } from "./util/date"
export namespace BlackData {
const Schema = z.object({
@@ -18,3 +20,73 @@ export namespace BlackData {
return Schema.parse(json)
})
}
export namespace Black {
export const analyzeRollingUsage = fn(
z.object({
usage: z.number().int(),
timeUpdated: z.date(),
}),
({ usage, timeUpdated }) => {
const now = new Date()
const black = BlackData.get()
const rollingWindowMs = black.rollingWindow * 3600 * 1000
const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100)
const windowStart = new Date(now.getTime() - rollingWindowMs)
if (timeUpdated < windowStart) {
return {
status: "ok" as const,
resetInSec: black.rollingWindow * 3600,
usagePercent: 0,
}
}
const windowEnd = new Date(timeUpdated.getTime() + rollingWindowMs)
if (usage < rollingLimitInMicroCents) {
return {
status: "ok" as const,
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
usagePercent: Math.ceil(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
}
}
return {
status: "rate-limited" as const,
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
usagePercent: 100,
}
},
)
export const analyzeWeeklyUsage = fn(
z.object({
usage: z.number().int(),
timeUpdated: z.date(),
}),
({ usage, timeUpdated }) => {
const black = BlackData.get()
const now = new Date()
const week = getWeekBounds(now)
const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100)
if (timeUpdated < week.start) {
return {
status: "ok" as const,
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
usagePercent: 0,
}
}
if (usage < fixedLimitInMicroCents) {
return {
status: "ok" as const,
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
usagePercent: Math.ceil(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
}
}
return {
status: "rate-limited" as const,
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
usagePercent: 100,
}
},
)
}

View File

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

View File

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

View File

@@ -17,7 +17,7 @@
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div>
<div id="root" class="flex flex-col h-dvh"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.19",
"version": "1.1.21",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -41,6 +41,7 @@ semver = "1.0.27"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
uuid = { version = "1.19.0", features = ["v4"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.1"

View File

@@ -7,6 +7,7 @@
"core:default",
"opener:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:webview:allow-set-webview-zoom",
"core:window:allow-is-focused",
"core:window:allow-show",

View File

@@ -14,7 +14,7 @@ use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow};
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_store::StoreExt;
@@ -223,7 +223,7 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
#[cfg(target_os = "macos")]
#[cfg(all(target_os = "macos", not(debug_assertions)))]
let _ = std::process::Command::new("killall")
.arg("opencode-cli")
.output();
@@ -237,7 +237,14 @@ pub fn run() {
}
}))
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_window_state::Builder::new().build())
.plugin(
tauri_plugin_window_state::Builder::new()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
- tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.build(),
)
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
@@ -268,29 +275,30 @@ pub fn run() {
.map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080));
#[allow(unused_mut)]
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode")
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
.initialization_script(format!(
r#"
let config = app
.config()
.app
.windows
.iter()
.find(|w| w.label == "main")
.expect("main window config missing");
let window_builder = WebviewWindowBuilder::from_config(&app, config)
.expect("Failed to create window builder from config")
.inner_size(size.width as f64, size.height as f64)
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
"#
));
));
#[cfg(target_os = "macos")]
{
window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
}
let window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
window_builder.build().expect("Failed to create window");
let _window = window_builder.build().expect("Failed to create window");
let (tx, rx) = oneshot::channel();
app.manage(ServerState::new(None, rx));

View File

@@ -11,6 +11,20 @@
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"create": false,
"title": "OpenCode",
"url": "/",
"decorations": true,
"dragDropEnabled": false,
"zoomHotkeysEnabled": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 12.0, "y": 18.0 }
}
],
"withGlobalTauri": true,
"security": {
"csp": null

View File

@@ -2,6 +2,27 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenCode",
"identifier": "ai.opencode.desktop",
"app": {
"windows": [
{
"label": "main",
"create": false,
"title": "OpenCode",
"url": "/",
"decorations": true,
"dragDropEnabled": false,
"zoomHotkeysEnabled": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 12.0, "y": 18.0 }
}
],
"withGlobalTauri": true,
"security": {
"csp": null
},
"macOSPrivateApi": true
},
"bundle": {
"createUpdaterArtifacts": true,
"icon": [

View File

@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource } from "solid-js"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
@@ -30,6 +30,11 @@ let update: Update | null = null
const createPlatform = (password: Accessor<string | null>): Platform => ({
platform: "desktop",
os: (() => {
const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type
return undefined
})(),
version: pkg.version,
async openDirectoryPickerDialog(opts) {
@@ -296,12 +301,24 @@ render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
e.preventDefault()
platform.openLink(link.href)
}
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
document.removeEventListener("click", handleClick)
})
})
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
{ostype() === "macos" && (
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
<ServerGate>
{(data) => {
setServerPassword(data().password)

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.19",
"version": "1.1.21",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.19"
version = "1.1.21"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.19/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.19/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.19/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.19/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.19/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

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

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.19",
"version": "1.1.21",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -70,7 +70,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.0",
"@gitlab/gitlab-ai-provider": "3.1.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -82,8 +82,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.72",
"@opentui/solid": "0.1.72",
"@opentui/core": "0.1.73",
"@opentui/solid": "0.1.73",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View File

@@ -13,6 +13,8 @@ import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission/next"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
export namespace Agent {
export const Info = z
@@ -88,9 +90,13 @@ export namespace Agent {
PermissionNext.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
".opencode/plans/*.md": "allow",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
}),
user,

View File

@@ -338,9 +338,9 @@ export const AuthLoginCommand = cmd({
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles)\n\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}

View File

@@ -5,7 +5,7 @@ import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { Keybind } from "@/util/keybind"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
export function useConnected() {
@@ -19,6 +19,7 @@ export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const keybind = useKeybind()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const [query, setQuery] = createSignal("")
@@ -207,14 +208,14 @@ export function DialogModel(props: { providerID?: string }) {
<DialogSelect
keybind={[
{
keybind: Keybind.parse("ctrl+a")[0],
keybind: keybind.all.model_provider_list?.[0],
title: connected() ? "Connect provider" : "View all providers",
onTrigger() {
dialog.replace(() => <DialogProvider />)
},
},
{
keybind: Keybind.parse("ctrl+f")[0],
keybind: keybind.all.model_favorite_toggle?.[0],
title: "Favorite",
disabled: !connected(),
onTrigger: (option) => {

View File

@@ -26,67 +26,82 @@ export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const connected = createMemo(() => new Set(sync.data.provider_next.connected))
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
map((provider) => {
const isConnected = connected().has(provider.id)
return {
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
footer: isConnected ? "Connected" : undefined,
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
))
}
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
},
}
}),
)
})
return options

View File

@@ -4,7 +4,7 @@ import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
@@ -14,9 +14,10 @@ import "opentui-spinner/solid"
export function DialogSessionList() {
const dialog = useDialog()
const sync = useSync()
const { theme } = useTheme()
const route = useRoute()
const sync = useSync()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
@@ -29,8 +30,6 @@ export function DialogSessionList() {
return result.data ?? []
})
const deleteKeybind = "ctrl+d"
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
@@ -52,7 +51,7 @@ export function DialogSessionList() {
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${deleteKeybind} again to confirm` : x.title,
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
@@ -89,7 +88,7 @@ export function DialogSessionList() {
}}
keybind={[
{
keybind: Keybind.parse(deleteKeybind)[0],
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
@@ -103,7 +102,7 @@ export function DialogSessionList() {
},
},
{
keybind: Keybind.parse("ctrl+r")[0],
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)

View File

@@ -2,8 +2,8 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { useKeybind } from "../context/keybind"
import { usePromptStash, type StashEntry } from "./prompt/stash"
function getRelativeTime(timestamp: number): string {
@@ -30,6 +30,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const { theme } = useTheme()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<number>()
@@ -41,7 +42,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? "Press ctrl+d again to confirm" : getStashPreview(entry.input),
title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
@@ -69,7 +70,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
}}
keybind={[
{
keybind: Keybind.parse("ctrl+d")[0],
keybind: keybind.all.stash_delete?.[0],
title: "delete",
onTrigger: (option) => {
if (toDelete() === option.value) {

View File

@@ -1,24 +1,85 @@
import { TextAttributes } from "@opentui/core"
import { For } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { TextAttributes, RGBA } from "@opentui/core"
import { For, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
// Shadow markers (rendered chars in parens):
// _ = full shadow cell (space with bg=shadow)
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
// ~ = shadow top only (▀ with fg=shadow)
const SHADOW_MARKER = /[_^~]/
const LOGO_RIGHT = [` `, `█▀▀ █▀▀█ █▀▀█ █▀▀`, `░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀ ▀▀▀▀ ▀▀▀`]
const LOGO_LEFT = [` `, `█▀▀ █▀▀█ █▀▀█ █▀▀`, `__█ █__█ █^^^ █__█`, `▀▀▀▀ ▀▀▀ ▀▀▀▀ ▀~~`]
const LOGO_RIGHT = [``, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█___ █__█ █__█ █^^^`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
export function Logo() {
const { theme } = useTheme()
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background, fg, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
const elements: JSX.Element[] = []
let i = 0
while (i < line.length) {
const rest = line.slice(i)
const markerIndex = rest.search(SHADOW_MARKER)
if (markerIndex === -1) {
elements.push(
<text fg={fg} attributes={attrs} selectable={false}>
{rest}
</text>,
)
break
}
if (markerIndex > 0) {
elements.push(
<text fg={fg} attributes={attrs} selectable={false}>
{rest.slice(0, markerIndex)}
</text>,
)
}
const marker = rest[markerIndex]
switch (marker) {
case "_":
elements.push(
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
{" "}
</text>,
)
break
case "^":
elements.push(
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
</text>,
)
break
case "~":
elements.push(
<text fg={shadow} attributes={attrs} selectable={false}>
</text>,
)
break
}
i += markerIndex + 1
}
return elements
}
return (
<box>
<For each={LOGO_LEFT}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted} selectable={false}>
{line}
</text>
<text fg={theme.text} attributes={TextAttributes.BOLD} selectable={false}>
{LOGO_RIGHT[index()]}
</text>
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
<box flexDirection="row">{renderLine(LOGO_RIGHT[index()], theme.text, true)}</box>
</box>
)}
</For>

View File

@@ -23,6 +23,7 @@ import type { FilePart } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { formatDuration } from "@/util/format"
import { createColors, createFrames } from "../../ui/spinner.ts"
import { useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
@@ -1037,7 +1038,8 @@ export function Prompt(props: PromptProps) {
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]`
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}

View File

@@ -139,7 +139,7 @@ const TIPS = [
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",

View File

@@ -417,6 +417,13 @@ async function getCustomThemes() {
return result
}
export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
const r = base.r + (overlay.r - base.r) * alpha
const g = base.g + (overlay.g - base.g) * alpha
const b = base.b + (overlay.b - base.b) * alpha
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
@@ -428,13 +435,6 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
return ansiToRgba(i)
}
const tint = (base: RGBA, overlay: RGBA, alpha: number) => {
const r = base.r + (overlay.r - base.r) * alpha
const g = base.g + (overlay.g - base.g) * alpha
const b = base.b + (overlay.b - base.b) * alpha
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
// Generate gray scale based on terminal background
const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark)

View File

@@ -1894,10 +1894,10 @@ function Question(props: ToolProps<typeof QuestionTool>) {
<Switch>
<Match when={props.metadata.answers}>
<BlockTool title="# Questions" part={props.part}>
<box>
<box gap={1}>
<For each={props.input.questions ?? []}>
{(q, i) => (
<box flexDirection="row" gap={1}>
<box flexDirection="column">
<text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box>

View File

@@ -132,6 +132,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
setStore("editing", false)
return
}
if (keybind.match("input_clear", evt)) {
evt.preventDefault()
const text = textarea?.plainText ?? ""
if (!text) {
setStore("editing", false)
return
}
textarea?.setText("")
return
}
if (evt.name === "return") {
evt.preventDefault()
const text = textarea?.plainText?.trim() ?? ""
@@ -142,16 +152,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const inputs = [...store.custom]
inputs[store.tab] = ""
setStore("custom", inputs)
}
const answers = [...store.answers]
if (prev) {
const answers = [...store.answers]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
setStore("answers", answers)
}
if (!prev) {
answers[store.tab] = []
}
setStore("answers", answers)
setStore("editing", false)
return
}
@@ -205,6 +210,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
} else {
const opts = options()
const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const digit = Number(evt.name)
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
evt.preventDefault()
const index = digit - 1
moveTo(index)
selectOption()
return
}
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
@@ -287,11 +302,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box flexDirection="row" gap={1}>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
{i() + 1}. {opt.label}
{multi()
? `${i() + 1}. [${picked() ? "✓" : " "}] ${opt.label}`
: `${i() + 1}. ${opt.label}`}
</text>
</box>
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
<Show when={!multi()}>
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
</Show>
</box>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{opt.description}</text>
</box>
@@ -304,16 +324,25 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{options().length + 1}. Type your own answer
{multi()
? `${options().length + 1}. [${customPicked() ? "✓" : " "}] Type your own answer`
: `${options().length + 1}. Type your own answer`}
</text>
</box>
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
<Show when={!multi()}>
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
</Show>
</box>
<Show when={store.editing}>
<box paddingLeft={3}>
<textarea
ref={(val: TextareaRenderable) => (textarea = val)}
focused
ref={(val: TextareaRenderable) => {
textarea = val
queueMicrotask(() => {
val.focus()
val.gotoLineEnd()
})
}}
initialValue={input()}
placeholder="Type your own answer"
textColor={theme.text}
@@ -343,9 +372,13 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{q.header}:</text>
<text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "}
<span style={{ fg: answered() ? theme.text : theme.error }}>
{answered() ? value() : "(not answered)"}
</span>
</text>
</box>
)
}}

View File

@@ -237,17 +237,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<Show when={diff().length <= 2 || expanded.diff}>
<For each={diff() || []}>
{(item) => {
const file = createMemo(() => {
const splits = item.file.split(path.sep).filter(Boolean)
const last = splits.at(-1)!
const rest = splits.slice(0, -1).join(path.sep)
if (!rest) return last
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
})
return (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} wrapMode="char">
{file()}
<text fg={theme.textMuted} wrapMode="none">
{item.file}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>

View File

@@ -21,7 +21,7 @@ export interface DialogSelectProps<T> {
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
keybind?: {
keybind: Keybind.Info
keybind?: Keybind.Info
title: string
disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void
@@ -109,15 +109,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => {
if (filter.length > 0) {
setStore("selected", 0)
} else if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
setStore("selected", currentIndex)
setTimeout(() => {
if (filter.length > 0) {
moveTo(0, true)
} else if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
moveTo(currentIndex, true)
}
}
}
scroll?.scrollTo(0)
}, 0)
}),
)
@@ -129,7 +130,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
moveTo(next)
}
function moveTo(next: number) {
function moveTo(next: number, center = false) {
setStore("selected", next)
props.onMove?.(selected()!)
if (!scroll) return
@@ -138,13 +139,18 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
if (!target) return
const y = target.y - scroll.y
if (y >= scroll.height) {
scroll.scrollBy(y - scroll.height + 1)
}
if (y < 0) {
scroll.scrollBy(y)
if (isDeepEqual(flat()[0].value, selected()?.value)) {
scroll.scrollTo(0)
if (center) {
const centerOffset = Math.floor(scroll.height / 2)
scroll.scrollBy(y - centerOffset)
} else {
if (y >= scroll.height) {
scroll.scrollBy(y - scroll.height + 1)
}
if (y < 0) {
scroll.scrollBy(y)
if (isDeepEqual(flat()[0].value, selected()?.value)) {
scroll.scrollTo(0)
}
}
}
}
@@ -166,7 +172,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
for (const item of props.keybind ?? []) {
if (item.disabled) continue
if (item.disabled || !item.keybind) continue
if (Keybind.match(item.keybind, keybind.parse(evt))) {
const s = selected()
if (s) {
@@ -188,7 +194,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled) ?? [])
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
return (
<box gap={1} paddingBottom={1}>

View File

@@ -16,7 +16,7 @@ export const UpgradeCommand = {
alias: "m",
describe: "installation method to use",
type: "string",
choices: ["curl", "npm", "pnpm", "bun", "brew"],
choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"],
})
},
handler: async (args: { target?: string; method?: string }) => {
@@ -56,8 +56,14 @@ export const UpgradeCommand = {
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) prompts.log.error(err.data.stderr)
else if (err instanceof Error) prompts.log.error(err.message)
if (err instanceof Installation.UpgradeFailedError) {
// necessary because choco only allows install/upgrade in elevated terminals
if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) {
prompts.log.error("Please run the terminal as Administrator and try again")
} else {
prompts.log.error(err.data.stderr)
}
} else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")
return
}

View File

@@ -28,7 +28,7 @@ export function FormatError(input: unknown) {
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
}
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
return input.data.message
}
if (Config.InvalidError.isInstance(input))
return [

View File

@@ -19,6 +19,8 @@ import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { existsSync } from "fs"
import { Bus } from "@/bus"
import { Session } from "@/session"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -231,8 +233,15 @@ export namespace Config {
dot: true,
cwd: dir,
})) {
const md = await ConfigMarkdown.parse(item)
if (!md.data) continue
const md = await ConfigMarkdown.parse(item).catch((err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse command ${item}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load command", { command: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
const file = rel(item, patterns) ?? path.basename(item)
@@ -263,8 +272,15 @@ export namespace Config {
dot: true,
cwd: dir,
})) {
const md = await ConfigMarkdown.parse(item)
if (!md.data) continue
const md = await ConfigMarkdown.parse(item).catch((err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse agent ${item}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load agent", { agent: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
const file = rel(item, patterns) ?? path.basename(item)
@@ -294,8 +310,15 @@ export namespace Config {
dot: true,
cwd: dir,
})) {
const md = await ConfigMarkdown.parse(item)
if (!md.data) continue
const md = await ConfigMarkdown.parse(item).catch((err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse mode ${item}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load mode", { mode: item, err })
return undefined
})
if (!md) continue
const config = {
name: path.basename(item, ".md"),
@@ -395,9 +418,7 @@ export namespace Config {
.int()
.positive()
.optional()
.describe(
"Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.",
),
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
@@ -436,9 +457,7 @@ export namespace Config {
.int()
.positive()
.optional()
.describe(
"Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.",
),
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
@@ -621,7 +640,11 @@ export namespace Config {
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
session_fork: z.string().optional().default("none").describe("Fork session from message"),
session_rename: z.string().optional().default("none").describe("Rename session"),
session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),

View File

@@ -14,8 +14,60 @@ export namespace ConfigMarkdown {
return Array.from(template.matchAll(SHELL_REGEX))
}
export function preprocessFrontmatter(content: string): string {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!match) return content
const frontmatter = match[1]
const lines = frontmatter.split("\n")
const result: string[] = []
for (const line of lines) {
// skip comments and empty lines
if (line.trim().startsWith("#") || line.trim() === "") {
result.push(line)
continue
}
// skip lines that are continuations (indented)
if (line.match(/^\s+/)) {
result.push(line)
continue
}
// match key: value pattern
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
if (!kvMatch) {
result.push(line)
continue
}
const key = kvMatch[1]
const value = kvMatch[2].trim()
// skip if value is empty, already quoted, or uses block scalar
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
result.push(line)
continue
}
// if value contains a colon, convert to block scalar
if (value.includes(":")) {
result.push(`${key}: |`)
result.push(` ${value}`)
continue
}
result.push(line)
}
const processed = result.join("\n")
return content.replace(frontmatter, () => processed)
}
export async function parse(filePath: string) {
const template = await Bun.file(filePath).text()
const raw = await Bun.file(filePath).text()
const template = preprocessFrontmatter(raw)
try {
const md = matter(template)
@@ -24,7 +76,7 @@ export namespace ConfigMarkdown {
throw new FrontmatterError(
{
path: filePath,
message: `Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
},
{ cause: err },
)

View File

@@ -33,7 +33,7 @@ await Promise.all([
fs.mkdir(Global.Path.bin, { recursive: true }),
])
const CACHE_VERSION = "17"
const CACHE_VERSION = "18"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()

View File

@@ -83,6 +83,14 @@ export namespace Installation {
name: "brew" as const,
command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
},
{
name: "scoop" as const,
command: () => $`scoop list opencode`.throws(false).quiet().text(),
},
{
name: "choco" as const,
command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(),
},
]
checks.sort((a, b) => {
@@ -95,7 +103,9 @@ export namespace Installation {
for (const check of checks) {
const output = await check.command()
if (output.includes(check.name === "brew" ? "opencode" : "opencode-ai")) {
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}
@@ -144,20 +154,28 @@ export namespace Installation {
})
break
}
case "choco":
cmd = $`echo Y | choco upgrade opencode --version=${target}`
break
case "scoop":
cmd = $`scoop install extras/opencode@${target}`
break
default:
throw new Error(`Unknown method: ${method}`)
}
const result = await cmd.quiet().throws(false)
if (result.exitCode !== 0) {
const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8")
throw new UpgradeFailedError({
stderr: stderr,
})
}
log.info("upgraded", {
method,
target,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
if (result.exitCode !== 0)
throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"),
})
await $`${process.execPath} --version`.nothrow().quiet().text()
}
@@ -195,6 +213,29 @@ export namespace Installation {
.then((data: any) => data.version)
}
if (detectedMethod === "choco") {
return fetch(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
{ headers: { Accept: "application/json;odata=verbose" } },
)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.d.results[0].Version)
}
if (detectedMethod === "scoop") {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Extras/master/bucket/opencode.json", {
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)

View File

@@ -109,7 +109,7 @@ export namespace MCP {
}
// Convert MCP tool definition to AI SDK Tool type
async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Promise<Tool> {
async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Promise<Tool> {
const inputSchema = mcpTool.inputSchema
// Spread first, then override type to ensure it's always "object"
@@ -119,7 +119,6 @@ export namespace MCP {
properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
additionalProperties: false,
}
const config = await Config.get()
return dynamicTool({
description: mcpTool.description ?? "",
@@ -133,7 +132,7 @@ export namespace MCP {
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
timeout: config.experimental?.mcp_timeout,
timeout,
},
)
},
@@ -556,7 +555,10 @@ export namespace MCP {
export async function tools() {
const result: Record<string, Tool> = {}
const s = await state()
const cfg = await Config.get()
const config = cfg.mcp ?? {}
const clientsSnapshot = await clients()
const defaultTimeout = cfg.experimental?.mcp_timeout
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
// Only include tools from connected MCPs (skip disabled ones)
@@ -577,10 +579,13 @@ export namespace MCP {
if (!toolsResult) {
continue
}
const mcpConfig = config[clientName]
const entry = isMcpConfigured(mcpConfig) ? mcpConfig : undefined
const timeout = entry?.timeout ?? defaultTimeout
for (const mcpTool of toolsResult.tools) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client)
result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client, timeout)
}
}
return result

View File

@@ -361,38 +361,6 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
}
}
if (!provider.models["gpt-5.2-codex"]) {
const model = {
id: "gpt-5.2-codex",
providerID: "openai",
api: {
id: "gpt-5.2-codex",
url: "https://chatgpt.com/backend-api/codex",
npm: "@ai-sdk/openai",
},
name: "GPT-5.2 Codex",
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 400000, output: 128000 },
status: "active" as const,
options: {},
headers: {},
release_date: "2025-12-18",
variants: {} as Record<string, Record<string, any>>,
family: "gpt-codex",
}
model.variants = ProviderTransform.variants(model)
provider.models["gpt-5.2-codex"] = model
}
// Zero out costs for Codex (included with ChatGPT subscription)
for (const model of Object.values(provider.models)) {
model.cost = {

View File

@@ -0,0 +1,249 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Installation } from "@/installation"
import { iife } from "@/util/iife"
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
function normalizeDomain(url: string) {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "")
}
function getUrls(domain: string) {
return {
DEVICE_CODE_URL: `https://${domain}/login/device/code`,
ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
}
}
export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
return {
auth: {
provider: "github-copilot",
async loader(getAuth, provider) {
const info = await getAuth()
if (!info || info.type !== "oauth") return {}
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
}
}
}
const enterpriseUrl = info.enterpriseUrl
const baseURL = enterpriseUrl
? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
: "https://api.githubcopilot.com"
return {
baseURL,
apiKey: "",
async fetch(request: RequestInfo | URL, init?: RequestInit) {
const info = await getAuth()
if (info.type !== "oauth") return fetch(request, init)
const { isVision, isAgent } = iife(() => {
try {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body
// Completions API
if (body?.messages) {
const last = body.messages[body.messages.length - 1]
return {
isVision: body.messages.some(
(msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
),
isAgent: last?.role !== "user",
}
}
// Responses API
if (body?.input) {
const last = body.input[body.input.length - 1]
return {
isVision: body.input.some(
(item: any) =>
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
),
isAgent: last?.role !== "user",
}
}
} catch {}
return { isVision: false, isAgent: false }
})
const headers: Record<string, string> = {
...(init?.headers as Record<string, string>),
"User-Agent": `opencode/${Installation.VERSION}`,
Authorization: `Bearer ${info.refresh}`,
"Openai-Intent": "conversation-edits",
"X-Initiator": isAgent ? "agent" : "user",
}
if (isVision) {
headers["Copilot-Vision-Request"] = "true"
}
delete headers["x-api-key"]
delete headers["authorization"]
return fetch(request, {
...init,
headers,
})
},
}
},
methods: [
{
type: "oauth",
label: "Login with GitHub Copilot",
prompts: [
{
type: "select",
key: "deploymentType",
message: "Select GitHub deployment type",
options: [
{
label: "GitHub.com",
value: "github.com",
hint: "Public",
},
{
label: "GitHub Enterprise",
value: "enterprise",
hint: "Data residency or self-hosted",
},
],
},
{
type: "text",
key: "enterpriseUrl",
message: "Enter your GitHub Enterprise URL or domain",
placeholder: "company.ghe.com or https://company.ghe.com",
condition: (inputs) => inputs.deploymentType === "enterprise",
validate: (value) => {
if (!value) return "URL or domain is required"
try {
const url = value.includes("://") ? new URL(value) : new URL(`https://${value}`)
if (!url.hostname) return "Please enter a valid URL or domain"
return undefined
} catch {
return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)"
}
},
},
],
async authorize(inputs = {}) {
const deploymentType = inputs.deploymentType || "github.com"
let domain = "github.com"
let actualProvider = "github-copilot"
if (deploymentType === "enterprise") {
const enterpriseUrl = inputs.enterpriseUrl
domain = normalizeDomain(enterpriseUrl!)
actualProvider = "github-copilot-enterprise"
}
const urls = getUrls(domain)
const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": `opencode/${Installation.VERSION}`,
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "read:user",
}),
})
if (!deviceResponse.ok) {
throw new Error("Failed to initiate device authorization")
}
const deviceData = (await deviceResponse.json()) as {
verification_uri: string
user_code: string
device_code: string
interval: number
}
return {
url: deviceData.verification_uri,
instructions: `Enter code: ${deviceData.user_code}`,
method: "auto" as const,
async callback() {
while (true) {
const response = await fetch(urls.ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": `opencode/${Installation.VERSION}`,
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code: deviceData.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
})
if (!response.ok) return { type: "failed" as const }
const data = (await response.json()) as {
access_token?: string
error?: string
}
if (data.access_token) {
const result: {
type: "success"
refresh: string
access: string
expires: number
provider?: string
enterpriseUrl?: string
} = {
type: "success",
refresh: data.access_token,
access: data.access_token,
expires: 0,
}
if (actualProvider === "github-copilot-enterprise") {
result.provider = "github-copilot-enterprise"
result.enterpriseUrl = domain
}
return result
}
if (data.error === "authorization_pending") {
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
continue
}
if (data.error) return { type: "failed" as const }
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
continue
}
},
}
},
},
],
},
}
}

View File

@@ -10,18 +10,15 @@ import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const BUILTIN = [
"opencode-copilot-auth@0.0.12",
"opencode-anthropic-auth@0.0.8",
"@gitlab/opencode-gitlab-auth@1.3.0",
]
const BUILTIN = ["opencode-anthropic-auth@0.0.9", "@gitlab/opencode-gitlab-auth@1.3.0"]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin]
const state = Instance.state(async () => {
const client = createOpencodeClient({
@@ -53,7 +50,7 @@ export namespace Plugin {
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth")) continue
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@")

View File

@@ -272,7 +272,11 @@ export namespace Project {
export async function list() {
const keys = await Storage.list(["project"])
return await Promise.all(keys.map((x) => Storage.read<Info>(x)))
const projects = await Promise.all(keys.map((x) => Storage.read<Info>(x)))
return projects.map((project) => ({
...project,
sandboxes: project.sandboxes?.filter((x) => existsSync(x)),
}))
}
export const update = fn(

View File

@@ -47,6 +47,7 @@ export namespace ModelsDev {
.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
}),
modalities: z
@@ -80,7 +81,11 @@ export namespace ModelsDev {
const file = Bun.file(filepath)
const result = await file.json().catch(() => {})
if (result) return result as Record<string, Provider>
const json = await data()
if (typeof data === "function") {
const json = await data()
return JSON.parse(json) as Record<string, Provider>
}
const json = await fetch("https://models.dev/api.json").then((x) => x.text())
return JSON.parse(json) as Record<string, Provider>
}

View File

@@ -197,16 +197,23 @@ export namespace Provider {
return undefined
})
if (!profile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
// Build credential provider options (only pass profile if specified)
const credentialProviderOptions = profile ? { profile } : {}
if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile) return { autoload: false }
const providerOptions: AmazonBedrockProviderSettings = {
region: defaultRegion,
credentialProvider: fromNodeProviderChain(credentialProviderOptions),
}
// Only use credential chain if no bearer token exists
// Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
if (!awsBearerToken) {
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
// Build credential provider options (only pass profile if specified)
const credentialProviderOptions = profile ? { profile } : {}
providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
}
// Add custom endpoint if specified (endpoint takes precedence over baseURL)
@@ -392,7 +399,7 @@ export namespace Provider {
},
}
},
async gitlab(input) {
gitlab: async (input) => {
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
const auth = await Auth.get(input.id)
@@ -416,10 +423,8 @@ export namespace Provider {
...(providerConfig?.options?.featureFlags || {}),
},
},
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: { anthropicModel?: string }) {
const anthropicModel = options?.anthropicModel
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
return sdk.agenticChat(modelID, {
anthropicModel,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
@@ -552,6 +557,7 @@ export namespace Provider {
}),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
}),
status: z.enum(["alpha", "beta", "deprecated", "active"]),
@@ -614,6 +620,7 @@ export namespace Provider {
},
limit: {
context: model.limit.context,
input: model.limit.input,
output: model.limit.output,
},
capabilities: {
@@ -865,7 +872,12 @@ export namespace Provider {
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
const data = database[providerID]
if (!data) {
log.error("Provider does not exist in model list " + providerID)
continue
}
const result = await fn(data)
if (result && (result.autoload || providers[providerID])) {
if (result.getModel) modelLoaders[providerID] = result.getModel
mergeProvider(providerID, {

View File

@@ -16,7 +16,31 @@ function mimeToModality(mime: string): Modality | undefined {
}
export namespace ProviderTransform {
function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
function normalizeMessages(
msgs: ModelMessage[],
model: Provider.Model,
options: Record<string, unknown>,
): ModelMessage[] {
// Strip openai itemId metadata following what codex does
if (model.api.npm === "@ai-sdk/openai" || options.store === false) {
msgs = msgs.map((msg) => {
if (!Array.isArray(msg.content)) return msg
const content = msg.content.map((part) => {
if (!part.providerOptions?.openai) return part
const { itemId, reasoningEncryptedContent, ...rest } = part.providerOptions.openai as Record<string, unknown>
const openai = Object.keys(rest).length > 0 ? rest : undefined
return {
...part,
providerOptions: {
...part.providerOptions,
openai,
},
}
})
return { ...msg, content } as typeof msg
})
}
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") {
@@ -218,9 +242,9 @@ export namespace ProviderTransform {
})
}
export function message(msgs: ModelMessage[], model: Provider.Model) {
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model)
msgs = normalizeMessages(msgs, model, options)
if (
model.providerID === "anthropic" ||
model.api.id.includes("anthropic") ||
@@ -453,64 +477,69 @@ export namespace ProviderTransform {
return {}
}
export function options(
model: Provider.Model,
sessionID: string,
providerOptions?: Record<string, any>,
): Record<string, any> {
export function options(input: {
model: Provider.Model
sessionID: string
providerOptions?: Record<string, any>
}): Record<string, any> {
const result: Record<string, any> = {}
if (model.api.npm === "@openrouter/ai-sdk-provider") {
// openai and providers using openai package should set store to false by default.
if (input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai") {
result["store"] = false
}
if (input.model.api.npm === "@openrouter/ai-sdk-provider") {
result["usage"] = {
include: true,
}
if (model.api.id.includes("gemini-3")) {
if (input.model.api.id.includes("gemini-3")) {
result["reasoning"] = { effort: "high" }
}
}
if (
model.providerID === "baseten" ||
(model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(model.api.id))
input.model.providerID === "baseten" ||
(input.model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(input.model.api.id))
) {
result["chat_template_args"] = { enable_thinking: true }
}
if (["zai", "zhipuai"].includes(model.providerID) && model.api.npm === "@ai-sdk/openai-compatible") {
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") {
result["thinking"] = {
type: "enabled",
clear_thinking: false,
}
}
if (model.providerID === "openai" || providerOptions?.setCacheKey) {
result["promptCacheKey"] = sessionID
if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) {
result["promptCacheKey"] = input.sessionID
}
if (model.api.npm === "@ai-sdk/google" || model.api.npm === "@ai-sdk/google-vertex") {
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
result["thinkingConfig"] = {
includeThoughts: true,
}
if (model.api.id.includes("gemini-3")) {
if (input.model.api.id.includes("gemini-3")) {
result["thinkingConfig"]["thinkingLevel"] = "high"
}
}
if (model.api.id.includes("gpt-5") && !model.api.id.includes("gpt-5-chat")) {
if (model.providerID.includes("codex")) {
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (input.model.providerID.includes("codex")) {
result["store"] = false
}
if (!model.api.id.includes("codex") && !model.api.id.includes("gpt-5-pro")) {
if (!input.model.api.id.includes("codex") && !input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
}
if (model.api.id.endsWith("gpt-5.") && model.providerID !== "azure") {
if (input.model.api.id.endsWith("gpt-5.") && input.model.providerID !== "azure") {
result["textVerbosity"] = "low"
}
if (model.providerID.startsWith("opencode")) {
result["promptCacheKey"] = sessionID
if (input.model.providerID.startsWith("opencode")) {
result["promptCacheKey"] = input.sessionID
result["include"] = ["reasoning.encrypted_content"]
result["reasoningSummary"] = "auto"
}
@@ -668,7 +697,10 @@ export namespace ProviderTransform {
export function error(providerID: string, error: APICallError) {
let message = error.message
if (providerID === "github-copilot" && message.includes("The requested model is not supported")) {
if (providerID.includes("github-copilot") && error.statusCode === 403) {
return "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode."
}
if (providerID.includes("github-copilot") && message.includes("The requested model is not supported")) {
return (
message +
"\n\nMake sure the model is enabled in your copilot settings: https://github.com/settings/copilot/features"

View File

@@ -724,6 +724,8 @@ export namespace Server {
validator(
"query",
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
.optional()
@@ -737,6 +739,8 @@ export namespace Server {
const term = query.search?.toLowerCase()
const sessions: Session.Info[] = []
for await (const session of Session.list()) {
if (query.directory !== undefined && session.directory !== query.directory) continue
if (query.roots && session.parentID) continue
if (query.start !== undefined && session.time.updated < query.start) continue
if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
sessions.push(session)

View File

@@ -34,7 +34,7 @@ export namespace SessionCompaction {
if (context === 0) return false
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX
const usable = context - output
const usable = input.model.limit.input || context - output
return count > usable
}

View File

@@ -1,5 +1,5 @@
import { Slug } from "@opencode-ai/util/slug"
import pat from "path"
import path from "path"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
@@ -21,7 +21,7 @@ import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
import path from "path"
import { Global } from "@/global"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -233,7 +233,10 @@ export namespace Session {
}
export function plan(input: { slug: string; time: { created: number } }) {
return path.join(Instance.worktree, ".opencode", "plans", [input.time.created, input.slug].join("-") + ".md")
const base = Instance.project.vcs
? path.join(Instance.worktree, ".opencode", "plans")
: path.join(Global.Path.data, "plans")
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
}
export const get = fn(Identifier.schema("session"), async (id) => {

View File

@@ -95,7 +95,11 @@ export namespace LLM {
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options(input.model, input.sessionID, provider.options)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: provider.options,
})
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
@@ -104,7 +108,6 @@ export namespace LLM {
)
if (isCodex) {
options.instructions = SystemPrompt.instructions()
options.store = false
}
const params = await Plugin.trigger(
@@ -214,7 +217,7 @@ export namespace LLM {
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model)
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},

View File

@@ -1229,11 +1229,13 @@ export namespace SessionPrompt {
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: BUILD_SWITCH.replace("{{plan}}", plan),
text:
BUILD_SWITCH + "\n\n" + `A plan file exists at ${plan}. You should execute on the plan defined within it`,
synthetic: true,
})
userMessage.parts.push(part)
}
return input.messages
}
// Entering plan mode

View File

@@ -2,6 +2,4 @@
Your operational mode has changed from plan to build.
You are no longer in read-only mode.
You are permitted to make file changes, run shell commands, and utilize your arsenal of tools as needed.
A plan file exists at {{plan}}. You should read this file and execute on the plan defined within it.
</system-reminder>

View File

@@ -1,4 +1,5 @@
import z from "zod"
import path from "path"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { NamedError } from "@opencode-ai/util/error"
@@ -6,8 +7,10 @@ import { ConfigMarkdown } from "../config/markdown"
import { Log } from "../util/log"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { exists } from "fs/promises"
import { Flag } from "@/flag/flag"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Session } from "@/session"
export namespace Skill {
const log = Log.create({ service: "skill" })
@@ -43,10 +46,16 @@ export namespace Skill {
const skills: Record<string, Info> = {}
const addSkill = async (match: string) => {
const md = await ConfigMarkdown.parse(match)
if (!md) {
return
}
const md = await ConfigMarkdown.parse(match).catch((err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse skill ${match}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err })
return undefined
})
if (!md) return
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return
@@ -77,7 +86,7 @@ export namespace Skill {
)
// Also include global ~/.claude/skills/
const globalClaude = `${Global.Path.home}/.claude`
if (await exists(globalClaude)) {
if (await Filesystem.isDir(globalClaude)) {
claudeDirs.push(globalClaude)
}

View File

@@ -2,6 +2,7 @@ import { Log } from "../util/log"
import path from "path"
import fs from "fs/promises"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy"
import { Lock } from "../util/lock"
import { $ } from "bun"
@@ -23,7 +24,7 @@ export namespace Storage {
const MIGRATIONS: Migration[] = [
async (dir) => {
const project = path.resolve(dir, "../project")
if (!fs.exists(project)) return
if (!(await Filesystem.isDir(project))) return
for await (const projectDir of new Bun.Glob("*").scan({
cwd: project,
onlyFiles: false,
@@ -43,7 +44,7 @@ export namespace Storage {
if (worktree) break
}
if (!worktree) continue
if (!(await fs.exists(worktree))) continue
if (!(await Filesystem.isDir(worktree))) continue
const [id] = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()

View File

@@ -129,7 +129,7 @@ export const EditTool = Tool.define("edit", {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
return {

View File

@@ -37,7 +37,15 @@ export const GrepTool = Tool.define("grep", {
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath()
const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern]
const args = [
"-nH",
"--hidden",
"--follow",
"--no-messages",
"--field-match-separator=|",
"--regexp",
params.pattern,
]
if (params.include) {
args.push("--glob", params.include)
}
@@ -52,7 +60,10 @@ export const GrepTool = Tool.define("grep", {
const errorOutput = await new Response(proc.stderr).text()
const exitCode = await proc.exited
if (exitCode === 1) {
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
@@ -60,10 +71,12 @@ export const GrepTool = Tool.define("grep", {
}
}
if (exitCode !== 0) {
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/)
const matches = []
@@ -124,6 +137,11 @@ export const GrepTool = Tool.define("grep", {
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
}
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
return {
title: params.pattern,
metadata: {

View File

@@ -59,7 +59,7 @@ export const WriteTool = Tool.define("write", {
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
if (file === normalizedFilepath) {
output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
continue
}
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue

View File

@@ -1,8 +1,18 @@
import { realpathSync } from "fs"
import { exists } from "fs/promises"
import { dirname, join, relative } from "path"
export namespace Filesystem {
export const exists = (p: string) =>
Bun.file(p)
.stat()
.then(() => true)
.catch(() => false)
export const isDir = (p: string) =>
Bun.file(p)
.stat()
.then((s) => s.isDirectory())
.catch(() => false)
/**
* On Windows, normalize a path to its canonical casing using the filesystem.
* This is needed because Windows paths are case-insensitive but LSP servers
@@ -31,7 +41,7 @@ export namespace Filesystem {
const result = []
while (true) {
const search = join(current, target)
if (await exists(search).catch(() => false)) result.push(search)
if (await exists(search)) result.push(search)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
@@ -46,7 +56,7 @@ export namespace Filesystem {
while (true) {
for (const target of targets) {
const search = join(current, target)
if (await exists(search).catch(() => false)) yield search
if (await exists(search)) yield search
}
if (stop === current) break
const parent = dirname(current)

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