Compare commits

...

106 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
110 changed files with 4655 additions and 1645 deletions

View File

@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.20",
"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.20",
"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.20",
"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.20",
"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.20",
"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.20",
"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.20",
"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.20",
"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.20",
"version": "1.1.21",
"bin": {
"opencode": "./bin/opencode",
},
@@ -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.20",
"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.20",
"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.20",
"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.20",
"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.20",
"version": "1.1.21",
"dependencies": {
"zod": "catalog:",
},
@@ -449,7 +450,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.20",
"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.6",
"@types/bun": "1.3.5",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -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.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@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.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"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

@@ -1,6 +1,6 @@
{
"nodeModules": {
"x86_64-linux": "sha256-GKdu7nan/9ioBtgL3cUeuVLNKUDio10LeQrn7BPgbng=",
"aarch64-darwin": "sha256-STLB1J65VjauvPM+BqCyTQQkHPoVmUhDvVEdH3WTJP4="
"x86_64-linux": "sha256-Fl1BdjNSg19LJVSgDMiBX8JuTaGlL2I5T+rqLfjSeO4=",
"aarch64-darwin": "sha256-7UajHu40n7JKqurU/+CGlitErsVFA2qDneUytI8+/zQ="
}
}

View File

@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.6",
"packageManager": "bun@1.3.5",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
@@ -21,7 +21,7 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.6",
"@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.20",
"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

@@ -124,12 +124,19 @@ function createGlobalSync() {
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 < limit) return true

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.20",
"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,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.1) 0%, rgba(0, 0, 0, 0) 100%);
inset: 0 0 auto 0;
height: 30dvh;
pointer-events: none;
z-index: 0;
}
[data-component="header"] {
@@ -48,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: 22px;
font-size: 20px;
}
@media (max-width: 480px) {
font-size: 14px;
}
}
p {
color: rgba(255, 255, 255, 0.59);
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: 22px;
font-size: 20px;
}
@media (max-width: 480px) {
font-size: 14px;
}
}
}
@@ -76,30 +219,36 @@
[data-slot="hero-black"] {
margin-top: 40px;
padding: 0 20px;
position: relative;
@media (min-width: 768px) {
margin-top: 60px;
}
svg {
--hero-black-fill-from: hsl(0 0% 100%);
--hero-black-fill-to: hsl(0 0% 100% / 0%);
--hero-black-stroke-from: hsl(0 0% 100% / 60%);
--hero-black-stroke-to: hsl(0 0% 100% / 0%);
width: 100%;
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-fill"] {
[data-slot="black-base"] {
fill: url(#hero-black-fill-gradient);
stroke: url(#hero-black-stroke-gradient);
}
[data-slot="black-stroke"] {
fill: 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;
}
}
}
@@ -107,14 +256,14 @@
[data-slot="cta"] {
display: flex;
flex-direction: column;
gap: 32px;
gap: 16px;
align-items: center;
text-align: center;
margin-top: -32px;
margin-top: -40px;
width: 100%;
@media (min-width: 768px) {
margin-top: -16px;
margin-top: -20px;
}
[data-slot="heading"] {
@@ -129,7 +278,6 @@
display: inline-block;
}
}
[data-slot="subheading"] {
color: rgba(255, 255, 255, 0.59);
font-size: 15px;
@@ -142,7 +290,6 @@
line-height: 160%;
}
}
[data-slot="button"] {
display: inline-flex;
height: 40px;
@@ -154,7 +301,7 @@
background: rgba(255, 255, 255, 0.92);
text-decoration: none;
color: #000;
font-family: var(--font-mono);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 500;
@@ -168,16 +315,14 @@
transform: scale(0.98);
}
}
[data-slot="back-soon"] {
color: rgba(255, 255, 255, 0.59);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%;
line-height: 160%; /* 20.8px */
}
[data-slot="follow-us"] {
display: inline-flex;
height: 40px;
@@ -201,98 +346,38 @@
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 680px;
max-width: 660px;
padding: 0 20px;
box-sizing: border-box;
@media (min-width: 768px) {
padding: 0;
}
}
[data-slot="pricing-card"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 5px;
background: black;
background-clip: padding-box;
border-radius: 4px;
text-decoration: none;
background: #000;
transition: border-color 0.15s ease;
cursor: pointer;
text-align: left;
overflow: hidden;
width: 100%;
transition: border-color 200ms ease;
&:hover:not([data-selected="true"]) {
@media (max-width: 480px) {
padding: 16px;
}
&:hover:not(:active) {
border-color: rgba(255, 255, 255, 0.35);
}
[data-slot="card-trigger"] {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 24px;
background: transparent;
border: none;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: padding 200ms ease;
&:disabled {
cursor: default;
}
}
&[data-selected="true"] {
[data-slot="amount"] {
font-size: 22px;
}
[data-slot="terms"] {
animation: reveal 500ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
}
[data-slot="actions"] {
[data-slot="continue"] {
animation-delay: 200ms;
}
}
}
&[data-collapsed="true"] {
[data-slot="card-trigger"] {
padding: 20px 24px;
}
[data-slot="plan-header"] {
flex-direction: row;
}
[data-slot="amount"] {
font-size: 20px;
}
}
&[data-selected="false"][data-collapsed="false"] {
[data-slot="amount"] {
font-size: 22px;
}
[data-slot="period"],
[data-slot="multiplier"] {
font-size: 14px;
}
}
[data-slot="plan-header"] {
display: flex;
flex-direction: column;
width: 100%;
gap: 12px;
transition: gap 200ms ease;
}
[data-slot="plan-icon"] {
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
flex-shrink: 0;
}
[data-slot="price"] {
@@ -300,31 +385,81 @@
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
line-height: 24px;
margin: 0;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="content"] {
width: 100%;
}
[data-slot="period"],
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="billing"] {
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before {
content: "·";
margin-right: 8px;
}
}
}
[data-slot="selected-plan"] {
display: flex;
flex-direction: column;
gap: 32px;
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: 12px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
width: 100%;
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before {
content: "·";
@@ -334,32 +469,30 @@
[data-slot="terms"] {
list-style: none;
padding: 0 24px 24px 24px;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
text-align: left;
width: 100%;
opacity: 0;
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);
mask-repeat: no-repeat;
mask-size: 100% 200%;
mask-position: 0% 320%;
}
[data-slot="terms"] li {
color: rgba(255, 255, 255, 0.59);
font-size: 13px;
line-height: 1.2;
padding-left: 16px;
position: relative;
li {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
line-height: 1.5;
padding-left: 16px;
position: relative;
&::before {
content: "▪";
position: absolute;
left: 0;
color: rgba(255, 255, 255, 0.39);
&::before {
content: "▪";
position: absolute;
left: 0;
color: rgba(255, 255, 255, 0.39);
}
@media (max-width: 768px) {
font-size: 12px;
}
}
}
@@ -367,48 +500,45 @@
display: flex;
gap: 16px;
margin-top: 8px;
padding: 0 24px 24px 24px;
box-sizing: border-box;
width: 100%;
}
[data-slot="actions"] button,
[data-slot="actions"] a {
flex: 1;
display: inline-flex;
height: 48px;
padding: 0 16px;
justify-content: center;
align-items: center;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 400;
text-decoration: none;
cursor: pointer;
transition-property: background-color, border-color;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
}
[data-slot="cancel"] {
border: 1px solid var(--border-base, rgba(255, 255, 255, 0.17));
background: var(--surface-raised-base, rgba(255, 255, 255, 0.06));
background-clip: border-box;
color: rgba(255, 255, 255, 0.92);
&:hover {
background: var(--surface-raised-base, rgba(255, 255, 255, 0.08));
border-color: rgba(255, 255, 255, 0.25);
button,
a {
flex: 1;
display: inline-flex;
height: 48px;
padding: 0 16px;
justify-content: center;
align-items: center;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 400;
text-decoration: none;
cursor: pointer;
}
}
[data-slot="continue"] {
background: rgb(255, 255, 255);
color: rgb(0, 0, 0);
[data-slot="cancel"] {
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 {
background: rgb(255, 255, 255, 0.9);
&:hover {
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.25);
}
}
[data-slot="continue"] {
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.9);
}
}
}
}
@@ -419,7 +549,8 @@
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%;
line-height: 160%; /* 20.8px */
font-style: italic;
a {
color: rgba(255, 255, 255, 0.39);
@@ -436,7 +567,7 @@
align-items: center;
margin-top: -18px;
width: 100%;
max-width: 540px;
max-width: 660px;
padding: 0 20px;
@media (min-width: 768px) {
@@ -491,7 +622,7 @@
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 13px;
font-size: 14px;
&::before {
content: "·";
@@ -510,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;
@@ -583,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;
@@ -645,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);
@@ -739,7 +792,7 @@
span,
a {
color: rgba(255, 255, 255, 0.39);
font-family: var(--font-mono);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
@@ -749,7 +802,7 @@
[data-slot="github-stars"] {
color: rgba(255, 255, 255, 0.25);
font-family: var(--font-mono);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
@@ -764,10 +817,9 @@
}
}
}
[data-slot="anomaly-alt"] {
color: rgba(255, 255, 255, 0.39);
font-family: var(--font-mono);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
@@ -777,7 +829,7 @@
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font", monospace;
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
@@ -791,15 +843,3 @@
}
}
}
::view-transition-group(*) {
animation-duration: 200ms;
animation-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
}
@keyframes reveal {
100% {
mask-position: 0% 0%;
opacity: 1;
}
}

View File

@@ -1,8 +1,9 @@
import { A, createAsync, RouteSectionProps } from "@solidjs/router"
import { Title, Meta, Link } from "@solidjs/meta"
import { createMemo } from "solid-js"
import { createMemo, createSignal } from "solid-js"
import { github } from "~/lib/github"
import { config } from "~/config"
import LightRays, { defaultConfig, type LightRaysConfig, type LightRaysAnimationState } from "~/component/light-rays"
import "./black.css"
export default function BlackLayout(props: RouteSectionProps) {
@@ -16,6 +17,49 @@ export default function BlackLayout(props: RouteSectionProps) {
: config.github.starsFormatted.compact,
)
const [lightRaysConfig, setLightRaysConfig] = createSignal<LightRaysConfig>(defaultConfig)
const [rayAnimationState, setRayAnimationState] = createSignal<LightRaysAnimationState>({
time: 0,
intensity: 0.5,
pulseValue: 1,
})
const svgLightingValues = createMemo(() => {
const state = rayAnimationState()
const t = state.time
const wave1 = Math.sin(t * 1.5) * 0.5 + 0.5
const wave2 = Math.sin(t * 2.3 + 1.2) * 0.5 + 0.5
const wave3 = Math.sin(t * 0.8 + 2.5) * 0.5 + 0.5
const shimmerPos = Math.sin(t * 0.7) * 0.5 + 0.5
const glowIntensity = state.intensity * state.pulseValue * 0.35
const fillOpacity = 0.1 + wave1 * 0.08 * state.pulseValue
const strokeBrightness = 55 + wave2 * 25 * state.pulseValue
const shimmerIntensity = wave3 * 0.15 * state.pulseValue
return {
glowIntensity,
fillOpacity,
strokeBrightness,
shimmerPos,
shimmerIntensity,
}
})
const svgLightingStyle = createMemo(() => {
const values = svgLightingValues()
return {
"--hero-black-glow-intensity": values.glowIntensity.toFixed(3),
"--hero-black-stroke-brightness": `${values.strokeBrightness.toFixed(0)}%`,
} as Record<string, string>
})
const handleAnimationFrame = (state: LightRaysAnimationState) => {
setRayAnimationState(state)
}
return (
<div data-page="black">
<Title>OpenCode Black | Access all the world's best coding models</Title>
@@ -39,7 +83,9 @@ export default function BlackLayout(props: RouteSectionProps) {
content="Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans."
/>
<Meta name="twitter:image" content="/social-share-black.png" />
<div data-component="header-gradient" />
<LightRays config={lightRaysConfig} class="header-light-rays" onAnimationFrame={handleAnimationFrame} />
<header data-component="header">
<A href="/" data-component="header-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="179" height="32" viewBox="0 0 179 32" fill="none">
@@ -112,15 +158,8 @@ export default function BlackLayout(props: RouteSectionProps) {
<h1>Access all the world's best coding models</h1>
<p>Including Claude, GPT, Gemini and more</p>
</div>
<div data-slot="hero-black">
<div data-slot="hero-black" style={svgLightingStyle()}>
<svg width="591" height="90" viewBox="0 0 591 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M425.56 0.75C429.464 0.750017 432.877 1.27807 435.78 2.35645C438.656 3.42455 441.138 4.86975 443.215 6.69727C445.268 8.50382 446.995 10.5587 448.394 12.8604C449.77 15.0464 450.986 17.2741 452.04 19.5439L452.357 20.2275L451.672 20.542L443.032 24.502L442.311 24.833L442.021 24.0938C441.315 22.2906 440.494 20.6079 439.557 19.0459L439.552 19.0391L439.548 19.0322C438.626 17.419 437.517 16.0443 436.223 14.9023L436.206 14.8867L436.189 14.8701C434.989 13.6697 433.518 12.7239 431.766 12.0381L431.755 12.0342V12.0332C430.111 11.3607 428.053 11.0098 425.56 11.0098C419.142 11.0098 414.433 13.4271 411.308 18.2295C408.212 23.109 406.629 29.6717 406.629 37.9805V51.6602C406.629 59.9731 408.214 66.5377 411.312 71.418C414.438 76.2157 419.145 78.6299 425.56 78.6299C428.054 78.6299 430.111 78.2782 431.756 77.6055L431.766 77.6016L432.413 77.333C433.893 76.6811 435.154 75.8593 436.206 74.873C437.512 73.644 438.625 72.2626 439.548 70.7275C440.489 69.0801 441.314 67.3534 442.021 65.5469L442.311 64.8076L443.032 65.1387L451.672 69.0986L452.348 69.4082L452.044 70.0869C450.99 72.439 449.773 74.7099 448.395 76.8994C446.995 79.1229 445.266 81.1379 443.215 82.9434C441.138 84.7708 438.656 86.2151 435.78 87.2832C432.877 88.3616 429.464 88.8896 425.56 88.8896C415.111 88.8896 407.219 85.0777 402.019 77.4004L402.016 77.3965C396.939 69.7818 394.449 58.891 394.449 44.8203C394.449 30.7495 396.939 19.8589 402.016 12.2441L402.019 12.2393C407.219 4.56202 415.111 0.75 425.56 0.75ZM29.9404 2.19043C37.2789 2.19051 43.125 4.19131 47.3799 8.2793C51.6307 12.3635 53.7305 17.8115 53.7305 24.54C53.7305 29.6953 52.4605 33.8451 49.835 36.8994L49.8359 36.9004C47.7064 39.4558 45.0331 41.367 41.835 42.6445C45.893 43.8751 49.3115 45.9006 52.0703 48.7295C55.2954 51.9546 56.8496 56.6143 56.8496 62.5801C56.8496 66.0251 56.2751 69.2753 55.1211 72.3252C53.9689 75.3702 52.3185 78.014 50.1689 80.249L50.1699 80.25C48.0996 82.4858 45.6172 84.2628 42.7314 85.582L42.7227 85.5859C39.9002 86.8312 36.8362 87.4502 33.54 87.4502H0.75V2.19043H29.9404ZM148.123 2.19043V77.1904H187.843V87.4502H136.543V2.19043H148.123ZM298.121 2.19043L298.283 2.71973L323.963 86.4805L324.261 87.4502H312.006L311.848 86.9131L304.927 63.5703H276.646L269.726 86.9131L269.566 87.4502H257.552L257.85 86.4805L283.529 2.71973L283.691 2.19043H298.121ZM539.782 2.19043V44.9209L549.845 32.2344L549.851 32.2275L549.855 32.2207L574.575 2.46094L574.801 2.19043H588.874L587.849 3.41992L558.795 38.2832L588.749 86.3027L589.464 87.4502H575.934L575.714 87.0938L550.937 46.9316L539.782 60.0947V87.4502H528.202V2.19043H539.782ZM12.3301 77.1904H30.54C35.0749 77.1904 38.5307 76.1729 40.9961 74.2305C43.4059 72.3317 44.6699 69.3811 44.6699 65.2197V60.2998C44.6699 56.2239 43.4093 53.3106 40.9961 51.4092L40.9854 51.4004C38.5207 49.3838 35.0691 48.3301 30.54 48.3301H12.3301V77.1904ZM279.485 53.3096H302.087L290.786 14.4482L279.485 53.3096ZM12.3301 38.5498H28.8604C33 38.5498 36.1378 37.6505 38.3633 35.9443C40.5339 34.2015 41.6698 31.5679 41.6699 27.9004V23.2197C41.6699 19.5455 40.5299 16.9088 38.3516 15.166C36.1272 13.3865 32.9938 12.4502 28.8604 12.4502H12.3301V38.5498Z"
fill="url(#hero-black-fill-gradient)"
fill-opacity="0.1"
stroke="url(#hero-black-stroke-gradient)"
stroke-width="1.5"
/>
<defs>
<linearGradient
id="hero-black-fill-gradient"
@@ -130,9 +169,10 @@ export default function BlackLayout(props: RouteSectionProps) {
y2="87.0326"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--hero-black-fill-from)" />
<stop offset="1" stop-color="var(--hero-black-fill-to)" />
<stop stop-color="white" />
<stop offset="1" stop-color="white" stop-opacity="0" />
</linearGradient>
<linearGradient
id="hero-black-stroke-gradient"
x1="290.82"
@@ -141,10 +181,80 @@ export default function BlackLayout(props: RouteSectionProps) {
y2="87.0325"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--hero-black-stroke-from)" />
<stop offset="1" stop-color="var(--hero-black-stroke-to)" />
<stop stop-color={`hsl(0 0% ${svgLightingValues().strokeBrightness}%)`} />
<stop offset="1" stop-color="white" stop-opacity="0" />
</linearGradient>
<linearGradient
id="hero-black-shimmer-gradient"
x1="0"
y1="0"
x2="591"
y2="0"
gradientUnits="userSpaceOnUse"
>
<stop offset={Math.max(0, svgLightingValues().shimmerPos - 0.12)} stop-color="transparent" />
<stop
offset={svgLightingValues().shimmerPos}
stop-color={`rgba(255, 255, 255, ${svgLightingValues().shimmerIntensity})`}
/>
<stop offset={Math.min(1, svgLightingValues().shimmerPos + 0.12)} stop-color="transparent" />
</linearGradient>
<linearGradient
id="hero-black-top-glow"
x1="290.82"
y1="0"
x2="290.82"
y2="45"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color={`rgba(255, 255, 255, ${svgLightingValues().glowIntensity})`} />
<stop offset="1" stop-color="transparent" />
</linearGradient>
<linearGradient
id="hero-black-shimmer-mask"
x1="290.82"
y1="0"
x2="290.82"
y2="50"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="white" />
<stop offset="0.8" stop-color="white" stop-opacity="0.5" />
<stop offset="1" stop-color="white" stop-opacity="0" />
</linearGradient>
<mask id="shimmer-top-mask">
<rect x="0" y="0" width="591" height="90" fill="url(#hero-black-shimmer-mask)" />
</mask>
</defs>
<path
d="M425.56 0.75C429.464 0.750017 432.877 1.27807 435.78 2.35645C438.656 3.42455 441.138 4.86975 443.215 6.69727C445.268 8.50382 446.995 10.5587 448.394 12.8604C449.77 15.0464 450.986 17.2741 452.04 19.5439L452.357 20.2275L451.672 20.542L443.032 24.502L442.311 24.833L442.021 24.0938C441.315 22.2906 440.494 20.6079 439.557 19.0459L439.552 19.0391L439.548 19.0322C438.626 17.419 437.517 16.0443 436.223 14.9023L436.206 14.8867L436.189 14.8701C434.989 13.6697 433.518 12.7239 431.766 12.0381L431.755 12.0342V12.0332C430.111 11.3607 428.053 11.0098 425.56 11.0098C419.142 11.0098 414.433 13.4271 411.308 18.2295C408.212 23.109 406.629 29.6717 406.629 37.9805V51.6602C406.629 59.9731 408.214 66.5377 411.312 71.418C414.438 76.2157 419.145 78.6299 425.56 78.6299C428.054 78.6299 430.111 78.2782 431.756 77.6055L431.766 77.6016L432.413 77.333C433.893 76.6811 435.154 75.8593 436.206 74.873C437.512 73.644 438.625 72.2626 439.548 70.7275C440.489 69.0801 441.314 67.3534 442.021 65.5469L442.311 64.8076L443.032 65.1387L451.672 69.0986L452.348 69.4082L452.044 70.0869C450.99 72.439 449.773 74.7099 448.395 76.8994C446.995 79.1229 445.266 81.1379 443.215 82.9434C441.138 84.7708 438.656 86.2151 435.78 87.2832C432.877 88.3616 429.464 88.8896 425.56 88.8896C415.111 88.8896 407.219 85.0777 402.019 77.4004L402.016 77.3965C396.939 69.7818 394.449 58.891 394.449 44.8203C394.449 30.7495 396.939 19.8589 402.016 12.2441L402.019 12.2393C407.219 4.56202 415.111 0.75 425.56 0.75ZM29.9404 2.19043C37.2789 2.19051 43.125 4.19131 47.3799 8.2793C51.6307 12.3635 53.7305 17.8115 53.7305 24.54C53.7305 29.6953 52.4605 33.8451 49.835 36.8994L49.8359 36.9004C47.7064 39.4558 45.0331 41.367 41.835 42.6445C45.893 43.8751 49.3115 45.9006 52.0703 48.7295C55.2954 51.9546 56.8496 56.6143 56.8496 62.5801C56.8496 66.0251 56.2751 69.2753 55.1211 72.3252C53.9689 75.3702 52.3185 78.014 50.1689 80.249L50.1699 80.25C48.0996 82.4858 45.6172 84.2628 42.7314 85.582L42.7227 85.5859C39.9002 86.8312 36.8362 87.4502 33.54 87.4502H0.75V2.19043H29.9404ZM148.123 2.19043V77.1904H187.843V87.4502H136.543V2.19043H148.123ZM298.121 2.19043L298.283 2.71973L323.963 86.4805L324.261 87.4502H312.006L311.848 86.9131L304.927 63.5703H276.646L269.726 86.9131L269.566 87.4502H257.552L257.85 86.4805L283.529 2.71973L283.691 2.19043H298.121ZM539.782 2.19043V44.9209L549.845 32.2344L549.851 32.2275L549.855 32.2207L574.575 2.46094L574.801 2.19043H588.874L587.849 3.41992L558.795 38.2832L588.749 86.3027L589.464 87.4502H575.934L575.714 87.0938L550.937 46.9316L539.782 60.0947V87.4502H528.202V2.19043H539.782ZM12.3301 77.1904H30.54C35.0749 77.1904 38.5307 76.1729 40.9961 74.2305C43.4059 72.3317 44.6699 69.3811 44.6699 65.2197V60.2998C44.6699 56.2239 43.4093 53.3106 40.9961 51.4092L40.9854 51.4004C38.5207 49.3838 35.0691 48.3301 30.54 48.3301H12.3301V77.1904ZM279.485 53.3096H302.087L290.786 14.4482L279.485 53.3096ZM12.3301 38.5498H28.8604C33 38.5498 36.1378 37.6505 38.3633 35.9443C40.5339 34.2015 41.6698 31.5679 41.6699 27.9004V23.2197C41.6699 19.5455 40.5299 16.9088 38.3516 15.166C36.1272 13.3865 32.9938 12.4502 28.8604 12.4502H12.3301V38.5498Z"
fill="url(#hero-black-fill-gradient)"
fill-opacity={svgLightingValues().fillOpacity}
stroke="url(#hero-black-stroke-gradient)"
stroke-width="1.5"
data-slot="black-base"
/>
<path
d="M425.56 0.75C429.464 0.750017 432.877 1.27807 435.78 2.35645C438.656 3.42455 441.138 4.86975 443.215 6.69727C445.268 8.50382 446.995 10.5587 448.394 12.8604C449.77 15.0464 450.986 17.2741 452.04 19.5439L452.357 20.2275L451.672 20.542L443.032 24.502L442.311 24.833L442.021 24.0938C441.315 22.2906 440.494 20.6079 439.557 19.0459L439.552 19.0391L439.548 19.0322C438.626 17.419 437.517 16.0443 436.223 14.9023L436.206 14.8867L436.189 14.8701C434.989 13.6697 433.518 12.7239 431.766 12.0381L431.755 12.0342V12.0332C430.111 11.3607 428.053 11.0098 425.56 11.0098C419.142 11.0098 414.433 13.4271 411.308 18.2295C408.212 23.109 406.629 29.6717 406.629 37.9805V51.6602C406.629 59.9731 408.214 66.5377 411.312 71.418C414.438 76.2157 419.145 78.6299 425.56 78.6299C428.054 78.6299 430.111 78.2782 431.756 77.6055L431.766 77.6016L432.413 77.333C433.893 76.6811 435.154 75.8593 436.206 74.873C437.512 73.644 438.625 72.2626 439.548 70.7275C440.489 69.0801 441.314 67.3534 442.021 65.5469L442.311 64.8076L443.032 65.1387L451.672 69.0986L452.348 69.4082L452.044 70.0869C450.99 72.439 449.773 74.7099 448.395 76.8994C446.995 79.1229 445.266 81.1379 443.215 82.9434C441.138 84.7708 438.656 86.2151 435.78 87.2832C432.877 88.3616 429.464 88.8896 425.56 88.8896C415.111 88.8896 407.219 85.0777 402.019 77.4004L402.016 77.3965C396.939 69.7818 394.449 58.891 394.449 44.8203C394.449 30.7495 396.939 19.8589 402.016 12.2441L402.019 12.2393C407.219 4.56202 415.111 0.75 425.56 0.75ZM29.9404 2.19043C37.2789 2.19051 43.125 4.19131 47.3799 8.2793C51.6307 12.3635 53.7305 17.8115 53.7305 24.54C53.7305 29.6953 52.4605 33.8451 49.835 36.8994L49.8359 36.9004C47.7064 39.4558 45.0331 41.367 41.835 42.6445C45.893 43.8751 49.3115 45.9006 52.0703 48.7295C55.2954 51.9546 56.8496 56.6143 56.8496 62.5801C56.8496 66.0251 56.2751 69.2753 55.1211 72.3252C53.9689 75.3702 52.3185 78.014 50.1689 80.249L50.1699 80.25C48.0996 82.4858 45.6172 84.2628 42.7314 85.582L42.7227 85.5859C39.9002 86.8312 36.8362 87.4502 33.54 87.4502H0.75V2.19043H29.9404ZM148.123 2.19043V77.1904H187.843V87.4502H136.543V2.19043H148.123ZM298.121 2.19043L298.283 2.71973L323.963 86.4805L324.261 87.4502H312.006L311.848 86.9131L304.927 63.5703H276.646L269.726 86.9131L269.566 87.4502H257.552L257.85 86.4805L283.529 2.71973L283.691 2.19043H298.121ZM539.782 2.19043V44.9209L549.845 32.2344L549.851 32.2275L549.855 32.2207L574.575 2.46094L574.801 2.19043H588.874L587.849 3.41992L558.795 38.2832L588.749 86.3027L589.464 87.4502H575.934L575.714 87.0938L550.937 46.9316L539.782 60.0947V87.4502H528.202V2.19043H539.782ZM12.3301 77.1904H30.54C35.0749 77.1904 38.5307 76.1729 40.9961 74.2305C43.4059 72.3317 44.6699 69.3811 44.6699 65.2197V60.2998C44.6699 56.2239 43.4093 53.3106 40.9961 51.4092L40.9854 51.4004C38.5207 49.3838 35.0691 48.3301 30.54 48.3301H12.3301V77.1904ZM279.485 53.3096H302.087L290.786 14.4482L279.485 53.3096ZM12.3301 38.5498H28.8604C33 38.5498 36.1378 37.6505 38.3633 35.9443C40.5339 34.2015 41.6698 31.5679 41.6699 27.9004V23.2197C41.6699 19.5455 40.5299 16.9088 38.3516 15.166C36.1272 13.3865 32.9938 12.4502 28.8604 12.4502H12.3301V38.5498Z"
fill="url(#hero-black-top-glow)"
stroke="none"
data-slot="black-glow"
/>
<path
d="M425.56 0.75C429.464 0.750017 432.877 1.27807 435.78 2.35645C438.656 3.42455 441.138 4.86975 443.215 6.69727C445.268 8.50382 446.995 10.5587 448.394 12.8604C449.77 15.0464 450.986 17.2741 452.04 19.5439L452.357 20.2275L451.672 20.542L443.032 24.502L442.311 24.833L442.021 24.0938C441.315 22.2906 440.494 20.6079 439.557 19.0459L439.552 19.0391L439.548 19.0322C438.626 17.419 437.517 16.0443 436.223 14.9023L436.206 14.8867L436.189 14.8701C434.989 13.6697 433.518 12.7239 431.766 12.0381L431.755 12.0342V12.0332C430.111 11.3607 428.053 11.0098 425.56 11.0098C419.142 11.0098 414.433 13.4271 411.308 18.2295C408.212 23.109 406.629 29.6717 406.629 37.9805V51.6602C406.629 59.9731 408.214 66.5377 411.312 71.418C414.438 76.2157 419.145 78.6299 425.56 78.6299C428.054 78.6299 430.111 78.2782 431.756 77.6055L431.766 77.6016L432.413 77.333C433.893 76.6811 435.154 75.8593 436.206 74.873C437.512 73.644 438.625 72.2626 439.548 70.7275C440.489 69.0801 441.314 67.3534 442.021 65.5469L442.311 64.8076L443.032 65.1387L451.672 69.0986L452.348 69.4082L452.044 70.0869C450.99 72.439 449.773 74.7099 448.395 76.8994C446.995 79.1229 445.266 81.1379 443.215 82.9434C441.138 84.7708 438.656 86.2151 435.78 87.2832C432.877 88.3616 429.464 88.8896 425.56 88.8896C415.111 88.8896 407.219 85.0777 402.019 77.4004L402.016 77.3965C396.939 69.7818 394.449 58.891 394.449 44.8203C394.449 30.7495 396.939 19.8589 402.016 12.2441L402.019 12.2393C407.219 4.56202 415.111 0.75 425.56 0.75ZM29.9404 2.19043C37.2789 2.19051 43.125 4.19131 47.3799 8.2793C51.6307 12.3635 53.7305 17.8115 53.7305 24.54C53.7305 29.6953 52.4605 33.8451 49.835 36.8994L49.8359 36.9004C47.7064 39.4558 45.0331 41.367 41.835 42.6445C45.893 43.8751 49.3115 45.9006 52.0703 48.7295C55.2954 51.9546 56.8496 56.6143 56.8496 62.5801C56.8496 66.0251 56.2751 69.2753 55.1211 72.3252C53.9689 75.3702 52.3185 78.014 50.1689 80.249L50.1699 80.25C48.0996 82.4858 45.6172 84.2628 42.7314 85.582L42.7227 85.5859C39.9002 86.8312 36.8362 87.4502 33.54 87.4502H0.75V2.19043H29.9404ZM148.123 2.19043V77.1904H187.843V87.4502H136.543V2.19043H148.123ZM298.121 2.19043L298.283 2.71973L323.963 86.4805L324.261 87.4502H312.006L311.848 86.9131L304.927 63.5703H276.646L269.726 86.9131L269.566 87.4502H257.552L257.85 86.4805L283.529 2.71973L283.691 2.19043H298.121ZM539.782 2.19043V44.9209L549.845 32.2344L549.851 32.2275L549.855 32.2207L574.575 2.46094L574.801 2.19043H588.874L587.849 3.41992L558.795 38.2832L588.749 86.3027L589.464 87.4502H575.934L575.714 87.0938L550.937 46.9316L539.782 60.0947V87.4502H528.202V2.19043H539.782ZM12.3301 77.1904H30.54C35.0749 77.1904 38.5307 76.1729 40.9961 74.2305C43.4059 72.3317 44.6699 69.3811 44.6699 65.2197V60.2998C44.6699 56.2239 43.4093 53.3106 40.9961 51.4092L40.9854 51.4004C38.5207 49.3838 35.0691 48.3301 30.54 48.3301H12.3301V77.1904ZM279.485 53.3096H302.087L290.786 14.4482L279.485 53.3096ZM12.3301 38.5498H28.8604C33 38.5498 36.1378 37.6505 38.3633 35.9443C40.5339 34.2015 41.6698 31.5679 41.6699 27.9004V23.2197C41.6699 19.5455 40.5299 16.9088 38.3516 15.166C36.1272 13.3865 32.9938 12.4502 28.8604 12.4502H12.3301V38.5498Z"
fill="url(#hero-black-shimmer-gradient)"
stroke="none"
data-slot="black-shimmer"
mask="url(#shimmer-top-mask)"
style={{ "mix-blend-mode": "overlay" }}
/>
</svg>
</div>
{props.children}

View File

@@ -1,12 +1,13 @@
import { A, useSearchParams } from "@solidjs/router"
import { Title } from "@solidjs/meta"
import { createMemo, createSignal, For, onMount, Show } 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))
@@ -37,110 +38,68 @@ export default function Black() {
<>
<Title>opencode</Title>
<section data-slot="cta">
<div data-slot="pricing">
<For each={plans}>
{(plan) => {
const isSelected = createMemo(() => selected() === plan.id)
const isCollapsed = createMemo(() => selected() !== null && selected() !== plan.id)
return (
<article
data-slot="pricing-card"
data-plan-id={plan.id}
data-selected={isSelected() ? "true" : "false"}
data-collapsed={isCollapsed() ? "true" : "false"}
>
<Switch>
<Match when={!selected()}>
<div data-slot="pricing">
<For each={plans}>
{(plan) => (
<button
type="button"
data-slot="card-trigger"
onClick={() => select(plan.id)}
disabled={isSelected()}
data-slot="pricing-card"
style={{ "view-transition-name": `card-${plan.id}` }}
>
<div
data-slot="plan-header"
style={{
"view-transition-name": `plan-header-${plan.id}`,
}}
>
<div data-slot="plan-icon">
<PlanIcon plan={plan.id} />
</div>
<p
data-slot="price"
style={{
"view-transition-name": `price-${plan.id}`,
}}
>
<span
data-slot="amount"
style={{
"view-transition-name": `amount-${plan.id}`,
}}
>
${plan.id}
</span>
<Show when={!isSelected()}>
<span
data-slot="period"
style={{
"view-transition-name": `period-${plan.id}`,
}}
>
per month
</span>
</Show>
<Show when={isSelected()}>
<span
data-slot="billing"
style={{
"view-transition-name": `billing-${plan.id}`,
}}
>
per person billed monthly
</span>
</Show>
{plan.multiplier && (
<span
data-slot="multiplier"
style={{
"view-transition-name": `multiplier-${plan.id}`,
}}
>
{plan.multiplier}
</span>
)}
</p>
<div data-slot="icon" style={{ "view-transition-name": `icon-${plan.id}` }}>
<PlanIcon plan={plan.id} />
</div>
<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>
</Show>
</p>
</button>
<Show when={isSelected()}>
<div data-slot="content">
<ul data-slot="terms">
<li>You will be added to the waitlist and activated in batches</li>
<li>Card won't be charged until subscription is active</li>
<li>Not unlimited - limits apply and may be adjusted dynamically</li>
<li>Heavily automated usage will hit limits quickly</li>
<li>Plans may be discontinued</li>
<li>Can cancel subscription at anytime</li>
<li>Cannot issue refunds for consumed subscriptions</li>
</ul>
<div data-slot="actions">
<button type="button" onClick={cancel} data-slot="cancel">
Cancel
</button>
<a href={`/black/subscribe/${plan.id}`} data-slot="continue">
Continue
</a>
</div>
</div>
</Show>
</article>
)
}}
</For>
</div>
<p data-slot="fine-print">
)}
</For>
</div>
</Match>
<Match when={selectedPlan()}>
{(plan) => (
<div data-slot="selected-plan">
<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" 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" 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>
<li>Usage limits apply, heavily automated use may reach limits sooner</li>
<li>Subscriptions for individuals, contact Enterprise for teams</li>
<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" 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">
Continue
</a>
</div>
</div>
</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

@@ -32,7 +32,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

@@ -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

@@ -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.20",
"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.20",
"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.20",
"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.20",
"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.20",
"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.20"
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.20/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.20/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.20/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.20/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.20/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.20",
"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.20",
"version": "1.1.21",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -557,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"]),
@@ -619,6 +620,7 @@ export namespace Provider {
},
limit: {
context: model.limit.context,
input: model.limit.input,
output: model.limit.output,
},
capabilities: {

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

@@ -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

@@ -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

@@ -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"
@@ -7,6 +8,9 @@ import { Log } from "../util/log"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
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" })
@@ -42,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

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

@@ -0,0 +1,20 @@
export function formatDuration(secs: number) {
if (secs <= 0) return ""
if (secs < 60) return `${secs}s`
if (secs < 3600) {
const mins = Math.floor(secs / 60)
const remaining = secs % 60
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
}
if (secs < 86400) {
const hours = Math.floor(secs / 3600)
const remaining = Math.floor((secs % 3600) / 60)
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
}
if (secs < 604800) {
const days = Math.floor(secs / 86400)
return days === 1 ? "~1 day" : `~${days} days`
}
const weeks = Math.floor(secs / 604800)
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
}

View File

@@ -10,8 +10,8 @@ export namespace Keybind {
leader: boolean // our custom field
}
export function match(a: Info, b: Info): boolean {
// Normalize super field (undefined and false are equivalent)
export function match(a: Info | undefined, b: Info): boolean {
if (!a) return false
const normalizedA = { ...a, super: a.super ?? false }
const normalizedB = { ...b, super: b.super ?? false }
return isDeepEqual(normalizedA, normalizedB)
@@ -32,7 +32,8 @@ export namespace Keybind {
}
}
export function toString(info: Info): string {
export function toString(info: Info | undefined): string {
if (!info) return ""
const parts: string[] = []
if (info.ctrl) parts.push("ctrl")

View File

@@ -0,0 +1,4 @@
---
---
Content

View File

@@ -0,0 +1,28 @@
---
description: "This is a description wrapped in quotes"
# field: this is a commented out field that should be ignored
occupation: This man has the following occupation: Software Engineer
title: 'Hello World'
name: John "Doe"
family: He has no 'family'
summary: >
This is a summary
url: https://example.com:8080/path?query=value
time: The time is 12:30:00 PM
nested: First: Second: Third: Fourth
quoted_colon: "Already quoted: no change needed"
single_quoted_colon: 'Single quoted: also fine'
mixed: He said "hello: world" and then left
empty:
dollar: Use $' and $& for special patterns
---
Content that should not be parsed:
fake_field: this is not yaml
another: neither is this
time: 10:30:00 AM
url: https://should-not-be-parsed.com:3000
The above lines look like YAML but are just content.

View File

@@ -0,0 +1 @@
Content

View File

@@ -1,89 +1,192 @@
import { expect, test } from "bun:test"
import { expect, test, describe } from "bun:test"
import { ConfigMarkdown } from "../../src/config/markdown"
const template = `This is a @valid/path/to/a/file and it should also match at
the beginning of a line:
describe("ConfigMarkdown: normal template", () => {
const template = `This is a @valid/path/to/a/file and it should also match at
the beginning of a line:
@another-valid/path/to/a/file
@another-valid/path/to/a/file
but this is not:
but this is not:
- Adds a "Co-authored-by:" footer which clarifies which AI agent
helped create this commit, using an appropriate \`noreply@...\`
or \`noreply@anthropic.com\` email address.
- Adds a "Co-authored-by:" footer which clarifies which AI agent
helped create this commit, using an appropriate \`noreply@...\`
or \`noreply@anthropic.com\` email address.
We also need to deal with files followed by @commas, ones
with @file-extensions.md, even @multiple.extensions.bak,
hidden directories like @.config/ or files like @.bashrc
and ones at the end of a sentence like @foo.md.
We also need to deal with files followed by @commas, ones
with @file-extensions.md, even @multiple.extensions.bak,
hidden directories like @.config/ or files like @.bashrc
and ones at the end of a sentence like @foo.md.
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
as well as @~/home-files and @~/paths/under/home.txt.
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
as well as @~/home-files and @~/paths/under/home.txt.
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
const matches = ConfigMarkdown.files(template)
const matches = ConfigMarkdown.files(template)
test("should extract exactly 12 file references", () => {
expect(matches.length).toBe(12)
test("should extract exactly 12 file references", () => {
expect(matches.length).toBe(12)
})
test("should extract valid/path/to/a/file", () => {
expect(matches[0][1]).toBe("valid/path/to/a/file")
})
test("should extract another-valid/path/to/a/file", () => {
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
})
test("should extract paths ignoring comma after", () => {
expect(matches[2][1]).toBe("commas")
})
test("should extract a path with a file extension and comma after", () => {
expect(matches[3][1]).toBe("file-extensions.md")
})
test("should extract a path with multiple dots and comma after", () => {
expect(matches[4][1]).toBe("multiple.extensions.bak")
})
test("should extract hidden directory", () => {
expect(matches[5][1]).toBe(".config/")
})
test("should extract hidden file", () => {
expect(matches[6][1]).toBe(".bashrc")
})
test("should extract a file ignoring period at end of sentence", () => {
expect(matches[7][1]).toBe("foo.md")
})
test("should extract an absolute path with an extension", () => {
expect(matches[8][1]).toBe("/absolute/paths.txt")
})
test("should extract an absolute path without an extension", () => {
expect(matches[9][1]).toBe("/without/extensions")
})
test("should extract an absolute path in home directory", () => {
expect(matches[10][1]).toBe("~/home-files")
})
test("should extract an absolute path under home directory", () => {
expect(matches[11][1]).toBe("~/paths/under/home.txt")
})
test("should not match when preceded by backtick", () => {
const backtickTest = "This `@should/not/match` should be ignored"
const backtickMatches = ConfigMarkdown.files(backtickTest)
expect(backtickMatches.length).toBe(0)
})
test("should not match email addresses", () => {
const emailTest = "Contact user@example.com for help"
const emailMatches = ConfigMarkdown.files(emailTest)
expect(emailMatches.length).toBe(0)
})
})
test("should extract valid/path/to/a/file", () => {
expect(matches[0][1]).toBe("valid/path/to/a/file")
describe("ConfigMarkdown: frontmatter parsing", async () => {
const parsed = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/frontmatter.md")
test("should parse without throwing", () => {
expect(parsed).toBeDefined()
expect(parsed.data).toBeDefined()
expect(parsed.content).toBeDefined()
})
test("should extract description field", () => {
expect(parsed.data.description).toBe("This is a description wrapped in quotes")
})
test("should extract occupation field with colon in value", () => {
expect(parsed.data.occupation).toBe("This man has the following occupation: Software Engineer\n")
})
test("should extract title field with single quotes", () => {
expect(parsed.data.title).toBe("Hello World")
})
test("should extract name field with embedded quotes", () => {
expect(parsed.data.name).toBe('John "Doe"')
})
test("should extract family field with embedded single quotes", () => {
expect(parsed.data.family).toBe("He has no 'family'")
})
test("should extract multiline summary field", () => {
expect(parsed.data.summary).toBe("This is a summary\n")
})
test("should not include commented fields in data", () => {
expect(parsed.data.field).toBeUndefined()
})
test("should extract URL with port", () => {
expect(parsed.data.url).toBe("https://example.com:8080/path?query=value\n")
})
test("should extract time with colons", () => {
expect(parsed.data.time).toBe("The time is 12:30:00 PM\n")
})
test("should extract value with multiple colons", () => {
expect(parsed.data.nested).toBe("First: Second: Third: Fourth\n")
})
test("should preserve already double-quoted values with colons", () => {
expect(parsed.data.quoted_colon).toBe("Already quoted: no change needed")
})
test("should preserve already single-quoted values with colons", () => {
expect(parsed.data.single_quoted_colon).toBe("Single quoted: also fine")
})
test("should extract value with quotes and colons mixed", () => {
expect(parsed.data.mixed).toBe('He said "hello: world" and then left\n')
})
test("should handle empty values", () => {
expect(parsed.data.empty).toBeNull()
})
test("should handle dollar sign replacement patterns literally", () => {
expect(parsed.data.dollar).toBe("Use $' and $& for special patterns")
})
test("should not parse fake yaml from content", () => {
expect(parsed.data.fake_field).toBeUndefined()
expect(parsed.data.another).toBeUndefined()
})
test("should extract content after frontmatter without modification", () => {
expect(parsed.content).toContain("Content that should not be parsed:")
expect(parsed.content).toContain("fake_field: this is not yaml")
expect(parsed.content).toContain("url: https://should-not-be-parsed.com:3000")
})
})
test("should extract another-valid/path/to/a/file", () => {
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
describe("ConfigMarkdown: frontmatter parsing w/ empty frontmatter", async () => {
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/empty-frontmatter.md")
test("should parse without throwing", () => {
expect(result).toBeDefined()
expect(result.data).toEqual({})
expect(result.content.trim()).toBe("Content")
})
})
test("should extract paths ignoring comma after", () => {
expect(matches[2][1]).toBe("commas")
})
describe("ConfigMarkdown: frontmatter parsing w/ no frontmatter", async () => {
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/no-frontmatter.md")
test("should extract a path with a file extension and comma after", () => {
expect(matches[3][1]).toBe("file-extensions.md")
})
test("should extract a path with multiple dots and comma after", () => {
expect(matches[4][1]).toBe("multiple.extensions.bak")
})
test("should extract hidden directory", () => {
expect(matches[5][1]).toBe(".config/")
})
test("should extract hidden file", () => {
expect(matches[6][1]).toBe(".bashrc")
})
test("should extract a file ignoring period at end of sentence", () => {
expect(matches[7][1]).toBe("foo.md")
})
test("should extract an absolute path with an extension", () => {
expect(matches[8][1]).toBe("/absolute/paths.txt")
})
test("should extract an absolute path without an extension", () => {
expect(matches[9][1]).toBe("/without/extensions")
})
test("should extract an absolute path in home directory", () => {
expect(matches[10][1]).toBe("~/home-files")
})
test("should extract an absolute path under home directory", () => {
expect(matches[11][1]).toBe("~/paths/under/home.txt")
})
test("should not match when preceded by backtick", () => {
const backtickTest = "This `@should/not/match` should be ignored"
const backtickMatches = ConfigMarkdown.files(backtickTest)
expect(backtickMatches.length).toBe(0)
})
test("should not match email addresses", () => {
const emailTest = "Contact user@example.com for help"
const emailMatches = ConfigMarkdown.files(emailTest)
expect(emailMatches.length).toBe(0)
test("should parse without throwing", () => {
expect(result).toBeDefined()
expect(result.data).toEqual({})
expect(result.content.trim()).toBe("Content")
})
})

View File

@@ -39,22 +39,34 @@ describe("ProviderTransform.options - setCacheKey", () => {
} as any
test("should set promptCacheKey when providerOptions.setCacheKey is true", () => {
const result = ProviderTransform.options(mockModel, sessionID, { setCacheKey: true })
const result = ProviderTransform.options({
model: mockModel,
sessionID,
providerOptions: { setCacheKey: true },
})
expect(result.promptCacheKey).toBe(sessionID)
})
test("should not set promptCacheKey when providerOptions.setCacheKey is false", () => {
const result = ProviderTransform.options(mockModel, sessionID, { setCacheKey: false })
const result = ProviderTransform.options({
model: mockModel,
sessionID,
providerOptions: { setCacheKey: false },
})
expect(result.promptCacheKey).toBeUndefined()
})
test("should not set promptCacheKey when providerOptions is undefined", () => {
const result = ProviderTransform.options(mockModel, sessionID, undefined)
const result = ProviderTransform.options({
model: mockModel,
sessionID,
providerOptions: undefined,
})
expect(result.promptCacheKey).toBeUndefined()
})
test("should not set promptCacheKey when providerOptions does not have setCacheKey", () => {
const result = ProviderTransform.options(mockModel, sessionID, {})
const result = ProviderTransform.options({ model: mockModel, sessionID, providerOptions: {} })
expect(result.promptCacheKey).toBeUndefined()
})
@@ -68,9 +80,27 @@ describe("ProviderTransform.options - setCacheKey", () => {
npm: "@ai-sdk/openai",
},
}
const result = ProviderTransform.options(openaiModel, sessionID, {})
const result = ProviderTransform.options({ model: openaiModel, sessionID, providerOptions: {} })
expect(result.promptCacheKey).toBe(sessionID)
})
test("should set store=false for openai provider", () => {
const openaiModel = {
...mockModel,
providerID: "openai",
api: {
id: "gpt-4",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
}
const result = ProviderTransform.options({
model: openaiModel,
sessionID,
providerOptions: {},
})
expect(result.store).toBe(false)
})
})
describe("ProviderTransform.maxOutputTokens", () => {
@@ -208,40 +238,44 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
},
] as any[]
const result = ProviderTransform.message(msgs, {
id: "deepseek/deepseek-chat",
providerID: "deepseek",
api: {
id: "deepseek-chat",
url: "https://api.deepseek.com",
npm: "@ai-sdk/openai-compatible",
},
name: "DeepSeek Chat",
capabilities: {
temperature: true,
reasoning: true,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: {
field: "reasoning_content",
const result = ProviderTransform.message(
msgs,
{
id: "deepseek/deepseek-chat",
providerID: "deepseek",
api: {
id: "deepseek-chat",
url: "https://api.deepseek.com",
npm: "@ai-sdk/openai-compatible",
},
name: "DeepSeek Chat",
capabilities: {
temperature: true,
reasoning: true,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: {
field: "reasoning_content",
},
},
cost: {
input: 0.001,
output: 0.002,
cache: { read: 0.0001, write: 0.0002 },
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2023-04-01",
},
cost: {
input: 0.001,
output: 0.002,
cache: { read: 0.0001, write: 0.0002 },
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2023-04-01",
})
{},
)
expect(result).toHaveLength(1)
expect(result[0].content).toEqual([
@@ -266,38 +300,42 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
},
] as any[]
const result = ProviderTransform.message(msgs, {
id: "openai/gpt-4",
providerID: "openai",
api: {
id: "gpt-4",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
const result = ProviderTransform.message(
msgs,
{
id: "openai/gpt-4",
providerID: "openai",
api: {
id: "gpt-4",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
name: "GPT-4",
capabilities: {
temperature: true,
reasoning: false,
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.03,
output: 0.06,
cache: { read: 0.001, write: 0.002 },
},
limit: {
context: 128000,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2023-04-01",
},
name: "GPT-4",
capabilities: {
temperature: true,
reasoning: false,
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.03,
output: 0.06,
cache: { read: 0.001, write: 0.002 },
},
limit: {
context: 128000,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2023-04-01",
})
{},
)
expect(result[0].content).toEqual([
{ type: "reasoning", text: "Should not be processed" },
@@ -351,7 +389,7 @@ describe("ProviderTransform.message - empty image handling", () => {
},
] as any[]
const result = ProviderTransform.message(msgs, mockModel)
const result = ProviderTransform.message(msgs, mockModel, {})
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(2)
@@ -375,7 +413,7 @@ describe("ProviderTransform.message - empty image handling", () => {
},
] as any[]
const result = ProviderTransform.message(msgs, mockModel)
const result = ProviderTransform.message(msgs, mockModel, {})
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(2)
@@ -397,7 +435,7 @@ describe("ProviderTransform.message - empty image handling", () => {
},
] as any[]
const result = ProviderTransform.message(msgs, mockModel)
const result = ProviderTransform.message(msgs, mockModel, {})
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(3)
@@ -450,7 +488,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
{ role: "user", content: "World" },
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
const result = ProviderTransform.message(msgs, anthropicModel, {})
expect(result).toHaveLength(2)
expect(result[0].content).toBe("Hello")
@@ -469,7 +507,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
const result = ProviderTransform.message(msgs, anthropicModel, {})
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(1)
@@ -488,7 +526,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
const result = ProviderTransform.message(msgs, anthropicModel, {})
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(1)
@@ -508,7 +546,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
{ role: "user", content: "World" },
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
const result = ProviderTransform.message(msgs, anthropicModel, {})
expect(result).toHaveLength(2)
expect(result[0].content).toBe("Hello")
@@ -526,7 +564,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
const result = ProviderTransform.message(msgs, anthropicModel, {})
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(1)
@@ -550,7 +588,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
const result = ProviderTransform.message(msgs, anthropicModel, {})
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(2)
@@ -577,7 +615,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
},
] as any[]
const result = ProviderTransform.message(msgs, openaiModel)
const result = ProviderTransform.message(msgs, openaiModel, {})
expect(result).toHaveLength(2)
expect(result[0].content).toBe("")
@@ -585,6 +623,223 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
})
})
describe("ProviderTransform.message - strip openai metadata when store=false", () => {
const openaiModel = {
id: "openai/gpt-5",
providerID: "openai",
api: {
id: "gpt-5",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
name: "GPT-5",
capabilities: {
temperature: true,
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.03, output: 0.06, cache: { read: 0.001, write: 0.002 } },
limit: { context: 128000, output: 4096 },
status: "active",
options: {},
headers: {},
} as any
test("strips itemId and reasoningEncryptedContent when store=false", () => {
const msgs = [
{
role: "assistant",
content: [
{
type: "reasoning",
text: "thinking...",
providerOptions: {
openai: {
itemId: "rs_123",
reasoningEncryptedContent: "encrypted",
},
},
},
{
type: "text",
text: "Hello",
providerOptions: {
openai: {
itemId: "msg_456",
},
},
},
],
},
] as any[]
const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[]
expect(result).toHaveLength(1)
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBeUndefined()
expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined()
})
test("strips itemId and reasoningEncryptedContent when store=false even when not openai", () => {
const zenModel = {
...openaiModel,
providerID: "zen",
}
const msgs = [
{
role: "assistant",
content: [
{
type: "reasoning",
text: "thinking...",
providerOptions: {
openai: {
itemId: "rs_123",
reasoningEncryptedContent: "encrypted",
},
},
},
{
type: "text",
text: "Hello",
providerOptions: {
openai: {
itemId: "msg_456",
},
},
},
],
},
] as any[]
const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[]
expect(result).toHaveLength(1)
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBeUndefined()
expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined()
})
test("preserves other openai options when stripping itemId", () => {
const msgs = [
{
role: "assistant",
content: [
{
type: "text",
text: "Hello",
providerOptions: {
openai: {
itemId: "msg_123",
otherOption: "value",
},
},
},
],
},
] as any[]
const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[]
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value")
})
test("strips metadata for openai package even when store is true", () => {
const msgs = [
{
role: "assistant",
content: [
{
type: "text",
text: "Hello",
providerOptions: {
openai: {
itemId: "msg_123",
},
},
},
],
},
] as any[]
// openai package always strips itemId regardless of store value
const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[]
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
})
test("strips metadata for non-openai packages when store is false", () => {
const anthropicModel = {
...openaiModel,
providerID: "anthropic",
api: {
id: "claude-3",
url: "https://api.anthropic.com",
npm: "@ai-sdk/anthropic",
},
}
const msgs = [
{
role: "assistant",
content: [
{
type: "text",
text: "Hello",
providerOptions: {
openai: {
itemId: "msg_123",
},
},
},
],
},
] as any[]
// store=false triggers stripping even for non-openai packages
const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[]
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
})
test("does not strip metadata for non-openai packages when store is not false", () => {
const anthropicModel = {
...openaiModel,
providerID: "anthropic",
api: {
id: "claude-3",
url: "https://api.anthropic.com",
npm: "@ai-sdk/anthropic",
},
}
const msgs = [
{
role: "assistant",
content: [
{
type: "text",
text: "Hello",
providerOptions: {
openai: {
itemId: "msg_123",
},
},
},
],
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123")
})
})
describe("ProviderTransform.variants", () => {
const createMockModel = (overrides: Partial<any> = {}): any => ({
id: "test/test-model",

View File

@@ -10,13 +10,19 @@ import type { Provider } from "../../src/provider/provider"
Log.init({ print: false })
function createModel(opts: { context: number; output: number; cost?: Provider.Model["cost"] }): Provider.Model {
function createModel(opts: {
context: number
output: number
input?: number
cost?: Provider.Model["cost"]
}): Provider.Model {
return {
id: "test-model",
providerID: "test",
name: "Test",
limit: {
context: opts.context,
input: opts.input,
output: opts.output,
},
cost: opts.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } },
@@ -70,6 +76,42 @@ describe("session.compaction.isOverflow", () => {
})
})
test("respects input limit for input caps", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
const tokens = { input: 271_000, output: 1_000, reasoning: 0, cache: { read: 2_000, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
test("returns false when input/output are within input caps", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
const tokens = { input: 200_000, output: 20_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
test("returns false when output within limit with input caps", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = createModel({ context: 200_000, input: 120_000, output: 10_000 })
const tokens = { input: 50_000, output: 9_999, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
test("returns false when model context limit is 0", async () => {
await using tmp = await tmpdir()
await Instance.provide({

View File

@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test"
import { formatDuration } from "../../src/util/format"
describe("util.format", () => {
describe("formatDuration", () => {
test("returns empty string for zero or negative values", () => {
expect(formatDuration(0)).toBe("")
expect(formatDuration(-1)).toBe("")
expect(formatDuration(-100)).toBe("")
})
test("formats seconds under a minute", () => {
expect(formatDuration(1)).toBe("1s")
expect(formatDuration(30)).toBe("30s")
expect(formatDuration(59)).toBe("59s")
})
test("formats minutes under an hour", () => {
expect(formatDuration(60)).toBe("1m")
expect(formatDuration(61)).toBe("1m 1s")
expect(formatDuration(90)).toBe("1m 30s")
expect(formatDuration(120)).toBe("2m")
expect(formatDuration(330)).toBe("5m 30s")
expect(formatDuration(3599)).toBe("59m 59s")
})
test("formats hours under a day", () => {
expect(formatDuration(3600)).toBe("1h")
expect(formatDuration(3660)).toBe("1h 1m")
expect(formatDuration(7200)).toBe("2h")
expect(formatDuration(8100)).toBe("2h 15m")
expect(formatDuration(86399)).toBe("23h 59m")
})
test("formats days under a week", () => {
expect(formatDuration(86400)).toBe("~1 day")
expect(formatDuration(172800)).toBe("~2 days")
expect(formatDuration(259200)).toBe("~3 days")
expect(formatDuration(604799)).toBe("~6 days")
})
test("formats weeks", () => {
expect(formatDuration(604800)).toBe("~1 week")
expect(formatDuration(1209600)).toBe("~2 weeks")
expect(formatDuration(1609200)).toBe("~2 weeks")
})
test("handles boundary values correctly", () => {
expect(formatDuration(59)).toBe("59s")
expect(formatDuration(60)).toBe("1m")
expect(formatDuration(3599)).toBe("59m 59s")
expect(formatDuration(3600)).toBe("1h")
expect(formatDuration(86399)).toBe("23h 59m")
expect(formatDuration(86400)).toBe("~1 day")
expect(formatDuration(604799)).toBe("~6 days")
expect(formatDuration(604800)).toBe("~1 week")
})
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.20",
"version": "1.1.21",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.20",
"version": "1.1.21",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -966,6 +966,22 @@ export type KeybindsConfig = {
* Rename session
*/
session_rename?: string
/**
* Delete session
*/
session_delete?: string
/**
* Delete stash entry
*/
stash_delete?: string
/**
* Open provider list from model dialog
*/
model_provider_list?: string
/**
* Toggle model favorite status
*/
model_favorite_toggle?: string
/**
* Share current session
*/
@@ -1410,6 +1426,7 @@ export type ProviderConfig = {
}
limit?: {
context: number
input?: number
output: number
}
modalities?: {
@@ -1482,7 +1499,7 @@ export type McpLocalConfig = {
*/
enabled?: boolean
/**
* Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.
* Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.
*/
timeout?: number
}
@@ -1526,7 +1543,7 @@ export type McpRemoteConfig = {
*/
oauth?: McpOAuthConfig | false
/**
* Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.
* Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.
*/
timeout?: number
}
@@ -1903,6 +1920,7 @@ export type Model = {
}
limit: {
context: number
input?: number
output: number
}
status: "alpha" | "beta" | "deprecated" | "active"
@@ -3808,6 +3826,7 @@ export type ProviderListResponses = {
}
limit: {
context: number
input?: number
output: number
}
modalities?: {

View File

@@ -3572,6 +3572,9 @@
"context": {
"type": "number"
},
"input": {
"type": "number"
},
"output": {
"type": "number"
}
@@ -8168,7 +8171,27 @@
},
"session_rename": {
"description": "Rename session",
"default": "none",
"default": "ctrl+r",
"type": "string"
},
"session_delete": {
"description": "Delete session",
"default": "ctrl+d",
"type": "string"
},
"stash_delete": {
"description": "Delete stash entry",
"default": "ctrl+d",
"type": "string"
},
"model_provider_list": {
"description": "Open provider list from model dialog",
"default": "ctrl+a",
"type": "string"
},
"model_favorite_toggle": {
"description": "Toggle model favorite status",
"default": "ctrl+f",
"type": "string"
},
"session_share": {
@@ -8848,6 +8871,9 @@
"context": {
"type": "number"
},
"input": {
"type": "number"
},
"output": {
"type": "number"
}
@@ -9007,7 +9033,7 @@
"type": "boolean"
},
"timeout": {
"description": "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.",
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
@@ -9073,7 +9099,7 @@
]
},
"timeout": {
"description": "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.",
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
@@ -9978,6 +10004,9 @@
"context": {
"type": "number"
},
"input": {
"type": "number"
},
"output": {
"type": "number"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.20",
"version": "1.1.21",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.20",
"version": "1.1.21",
"type": "module",
"license": "MIT",
"exports": {
@@ -48,10 +48,10 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"dompurify": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",

View File

@@ -0,0 +1,55 @@
[data-slot="hover-card-trigger"] {
display: inline-flex;
}
[data-component="hover-card-content"] {
z-index: 50;
min-width: 200px;
max-width: 320px;
border-radius: var(--radius-md);
background-color: var(--surface-raised-stronger-non-alpha);
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
background-clip: padding-box;
box-shadow: var(--shadow-md);
transform-origin: var(--kb-hovercard-content-transform-origin);
&:focus-within {
outline: none;
}
&[data-closed] {
animation: hover-card-close 0.15s ease-out;
}
&[data-expanded] {
animation: hover-card-open 0.15s ease-out;
}
[data-slot="hover-card-body"] {
padding: 12px;
}
}
@keyframes hover-card-open {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes hover-card-close {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}

View File

@@ -0,0 +1,31 @@
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
import { ComponentProps, JSXElement, ParentProps, splitProps } from "solid-js"
export interface HoverCardProps extends ParentProps, Omit<ComponentProps<typeof Kobalte>, "children"> {
trigger: JSXElement
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
}
export function HoverCard(props: HoverCardProps) {
const [local, rest] = splitProps(props, ["trigger", "class", "classList", "children"])
return (
<Kobalte gutter={4} {...rest}>
<Kobalte.Trigger as="div" data-slot="hover-card-trigger">
{local.trigger}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
data-component="hover-card-content"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
<div data-slot="hover-card-body">{local.children}</div>
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}

View File

@@ -67,6 +67,12 @@
[data-slot="icon-svg"] {
color: var(--icon-strong-base);
}
&:disabled {
background-color: var(--icon-strong-disabled);
color: var(--icon-invert-base);
cursor: not-allowed;
}
}
&[data-variant="ghost"] {
@@ -99,6 +105,10 @@
/* color: var(--icon-selected); */
/* } */
}
&:disabled {
color: var(--icon-invert-base);
cursor: not-allowed;
}
}
&[data-size="normal"] {
@@ -129,12 +139,6 @@
letter-spacing: var(--letter-spacing-normal);
}
&:disabled {
background-color: var(--icon-strong-disabled);
color: var(--icon-invert-base);
cursor: not-allowed;
}
&:focus {
outline: none;
}

View File

@@ -45,7 +45,6 @@ const icons = {
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
"speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`,
"folder-add-left": `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,
"settings-gear": ` <path d="M9.99999 1L18 5.49998L18 14.5001L9.99998 19L2 14.5003L2 5.49996L9.99999 1Z" stroke="currentColor" stroke-linecap="square"/><path d="M13.2941 10.0001C13.2941 11.8313 11.8193 13.3159 10 13.3159C8.18073 13.3159 6.7059 11.8313 6.7059 10.0001C6.7059 8.16879 8.18073 6.68425 10 6.68425C11.8193 6.68425 13.2941 8.16879 13.2941 10.0001Z" stroke="currentColor" stroke-linecap="square"/>`,
github: `<path d="M10.0001 1.62549C14.6042 1.62549 18.3334 5.35465 18.3334 9.95882C18.333 11.7049 17.785 13.4068 16.7666 14.8251C15.7482 16.2434 14.3107 17.3066 12.6563 17.8651C12.2397 17.9484 12.0834 17.688 12.0834 17.4692C12.0834 17.188 12.0938 16.2922 12.0938 15.1776C12.0938 14.3963 11.8334 13.8963 11.5313 13.6359C13.3855 13.4276 15.3334 12.7192 15.3334 9.52132C15.3334 8.60465 15.0105 7.86507 14.4792 7.28174C14.5626 7.0734 14.8542 6.21924 14.3959 5.0734C14.3959 5.0734 13.698 4.84424 12.1042 5.92757C11.4376 5.74007 10.7292 5.64632 10.0209 5.64632C9.31258 5.64632 8.60425 5.74007 7.93758 5.92757C6.34383 4.85465 5.64592 5.0734 5.64592 5.0734C5.18758 6.21924 5.47925 7.0734 5.56258 7.28174C5.03133 7.86507 4.70842 8.61507 4.70842 9.52132C4.70842 12.7088 6.64592 13.4276 8.50008 13.6359C8.2605 13.8442 8.04175 14.2088 7.96883 14.7505C7.48967 14.9692 6.29175 15.3234 5.54175 14.063C5.3855 13.813 4.91675 13.1984 4.2605 13.2088C3.56258 13.2192 3.97925 13.6047 4.27092 13.7609C4.62508 13.9588 5.03133 14.6984 5.12508 14.938C5.29175 15.4067 5.83342 16.3026 7.92717 15.9172C7.92717 16.6151 7.93758 17.2713 7.93758 17.4692C7.93758 17.688 7.78133 17.938 7.36467 17.8651C5.70491 17.3126 4.26126 16.2515 3.23851 14.8324C2.21576 13.4133 1.66583 11.7081 1.66675 9.95882C1.66675 5.35465 5.39592 1.62549 10.0001 1.62549Z" fill="currentColor"/>`,
discord: `<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" fill="currentColor"/>`,
"layout-bottom": `<path d="M2.91699 17.0832L2.41699 17.0832L2.41699 17.5832L2.91699 17.5832L2.91699 17.0832ZM2.91699 2.91653L2.91699 2.41653L2.41699 2.41653L2.41699 2.91653L2.91699 2.91653ZM17.0837 2.91653L17.5837 2.91653L17.5837 2.41653L17.0837 2.41653L17.0837 2.91653ZM17.0837 17.0832L17.5837 17.0832L17.5837 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.5827L17.5837 12.5827L17.5837 11.5827L17.0837 11.5827L17.0837 12.0827L17.0837 12.5827ZM2.91699 11.5827L2.41699 11.5827L2.41699 12.5827L2.91699 12.5827L2.91699 12.0827L2.91699 11.5827ZM2.91699 17.0832L3.41699 17.0832L3.41699 2.91653L2.91699 2.91653L2.41699 2.91653L2.41699 17.0832L2.91699 17.0832ZM2.91699 2.91653L2.91699 3.41653L17.0837 3.41653L17.0837 2.91653L17.0837 2.41653L2.91699 2.41653L2.91699 2.91653ZM17.0837 2.91653L16.5837 2.91653L16.5837 17.0832L17.0837 17.0832L17.5837 17.0832L17.5837 2.91653L17.0837 2.91653ZM17.0837 17.0832L17.0837 16.5832L2.91699 16.5832L2.91699 17.0832L2.91699 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.0827L17.0837 11.5827L2.91699 11.5827L2.91699 12.0827L2.91699 12.5827L17.0837 12.5827L17.0837 12.0827Z" fill="currentColor"/>`,
@@ -60,7 +59,10 @@ const icons = {
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
menu: `<path d="M2.5 5H17.5M2.5 10H17.5M2.5 15H17.5" stroke="currentColor" stroke-linecap="square"/>`,
server: `<rect x="3.35547" y="1.92969" width="13.2857" height="16.1429" stroke="currentColor"/><rect x="3.35547" y="11.9297" width="13.2857" height="6.14286" stroke="currentColor"/><rect x="12.8555" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/><rect x="10" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/>`,
branch: `<path d="M14.1667 9.9987V10.4987H14.6667V9.9987H14.1667ZM5.83333 9.9987V9.4987H5.33333V9.9987H5.83333ZM6.33333 6.66536V6.16536H5.33333V6.66536H5.83333H6.33333ZM14.6667 6.66536V6.16536H13.6667V6.66536H14.1667H14.6667ZM5.33333 13.332C5.33333 13.6082 5.55719 13.832 5.83333 13.832C6.10948 13.832 6.33333 13.6082 6.33333 13.332H5.83333H5.33333ZM7.91667 4.16536H7.41667C7.41667 5.03982 6.70778 5.7487 5.83333 5.7487V6.2487V6.7487C7.26007 6.7487 8.41667 5.5921 8.41667 4.16536H7.91667ZM5.83333 6.2487V5.7487C4.95888 5.7487 4.25 5.03982 4.25 4.16536H3.75H3.25C3.25 5.5921 4.4066 6.7487 5.83333 6.7487V6.2487ZM3.75 4.16536H4.25C4.25 3.29091 4.95888 2.58203 5.83333 2.58203V2.08203V1.58203C4.4066 1.58203 3.25 2.73863 3.25 4.16536H3.75ZM5.83333 2.08203V2.58203C6.70778 2.58203 7.41667 3.29091 7.41667 4.16536H7.91667H8.41667C8.41667 2.73863 7.26007 1.58203 5.83333 1.58203V2.08203ZM7.91667 15.832H7.41667C7.41667 16.7065 6.70778 17.4154 5.83333 17.4154V17.9154V18.4154C7.26007 18.4154 8.41667 17.2588 8.41667 15.832H7.91667ZM5.83333 17.9154V17.4154C4.95888 17.4154 4.25 16.7065 4.25 15.832H3.75H3.25C3.25 17.2588 4.4066 18.4154 5.83333 18.4154V17.9154ZM3.75 15.832H4.25C4.25 14.9576 4.95888 14.2487 5.83333 14.2487V13.7487V13.2487C4.4066 13.2487 3.25 14.4053 3.25 15.832H3.75ZM5.83333 13.7487V14.2487C6.70778 14.2487 7.41667 14.9576 7.41667 15.832H7.91667H8.41667C8.41667 14.4053 7.26007 13.2487 5.83333 13.2487V13.7487ZM14.1667 9.9987V9.4987H5.83333V9.9987V10.4987H14.1667V9.9987ZM16.25 4.16536H15.75C15.75 5.03982 15.0411 5.7487 14.1667 5.7487V6.2487V6.7487C15.5934 6.7487 16.75 5.5921 16.75 4.16536H16.25ZM14.1667 6.2487V5.7487C13.2922 5.7487 12.5833 5.03982 12.5833 4.16536H12.0833H11.5833C11.5833 5.5921 12.7399 6.7487 14.1667 6.7487V6.2487ZM12.0833 4.16536H12.5833C12.5833 3.29091 13.2922 2.58203 14.1667 2.58203V2.08203V1.58203C12.7399 1.58203 11.5833 2.73863 11.5833 4.16536H12.0833ZM14.1667 2.08203V2.58203C15.0411 2.58203 15.75 3.29091 15.75 4.16536H16.25H16.75C16.75 2.73863 15.5934 1.58203 14.1667 1.58203V2.08203ZM14.1667 6.66536H13.6667V9.9987H14.1667H14.6667V6.66536H14.1667ZM5.83333 6.66536H5.33333V13.332H5.83333H6.33333V6.66536H5.83333ZM5.83333 9.9987H5.33333V13.332H5.83333H6.33333V9.9987H5.83333Z" fill="currentColor"/>`,
branch: `<path d="M14.2036 7.19987L14.2079 6.69989L13.2079 6.69132L13.2036 7.1913L13.7036 7.19559L14.2036 7.19987ZM8.14804 5.09032H7.64804C7.64804 5.75797 7.06861 6.34471 6.29619 6.34471V6.84471V7.34471C7.56926 7.34471 8.64804 6.36051 8.64804 5.09032H8.14804ZM6.29619 6.84471V6.34471C5.52376 6.34471 4.94434 5.75797 4.94434 5.09032H4.44434H3.94434C3.94434 6.36051 5.02311 7.34471 6.29619 7.34471V6.84471ZM4.44434 5.09032H4.94434C4.94434 4.42267 5.52376 3.83594 6.29619 3.83594V3.33594V2.83594C5.02311 2.83594 3.94434 3.82013 3.94434 5.09032H4.44434ZM6.29619 3.33594V3.83594C7.06861 3.83594 7.64804 4.42267 7.64804 5.09032H8.14804H8.64804C8.64804 3.82013 7.56926 2.83594 6.29619 2.83594V3.33594ZM8.14804 14.9149H7.64804C7.64804 15.5825 7.06861 16.1693 6.29619 16.1693V16.6693V17.1693C7.56926 17.1693 8.64804 16.1851 8.64804 14.9149H8.14804ZM6.29619 16.6693V16.1693C5.52376 16.1693 4.94434 15.5825 4.94434 14.9149H4.44434H3.94434C3.94434 16.1851 5.02311 17.1693 6.29619 17.1693V16.6693ZM4.44434 14.9149H4.94434C4.94434 14.2472 5.52376 13.6605 6.29619 13.6605V13.1605V12.6605C5.02311 12.6605 3.94434 13.6447 3.94434 14.9149H4.44434ZM6.29619 13.1605V13.6605C7.06861 13.6605 7.64804 14.2472 7.64804 14.9149H8.14804H8.64804C8.64804 13.6447 7.56926 12.6605 6.29619 12.6605V13.1605ZM15.5554 5.09032H15.0554C15.0554 5.75797 14.476 6.34471 13.7036 6.34471V6.84471V7.34471C14.9767 7.34471 16.0554 6.36051 16.0554 5.09032H15.5554ZM13.7036 6.84471V6.34471C12.9312 6.34471 12.3517 5.75797 12.3517 5.09032H11.8517H11.3517C11.3517 6.36051 12.4305 7.34471 13.7036 7.34471V6.84471ZM11.8517 5.09032H12.3517C12.3517 4.42267 12.9312 3.83594 13.7036 3.83594V3.33594V2.83594C12.4305 2.83594 11.3517 3.82013 11.3517 5.09032H11.8517ZM13.7036 3.33594V3.83594C14.476 3.83594 15.0554 4.42267 15.0554 5.09032H15.5554H16.0554C16.0554 3.82013 14.9767 2.83594 13.7036 2.83594V3.33594ZM13.7036 7.19559L13.2036 7.1913L13.1544 12.9277L13.6544 12.932L14.1544 12.9363L14.2036 7.19987L13.7036 7.19559ZM6.29619 6.84471H5.79619V13.1605H6.29619H6.79619V6.84471H6.29619ZM11.6545 14.9149V14.4149H8.14804V14.9149V15.4149H11.6545V14.9149ZM13.6544 12.932L13.1544 12.9277C13.1474 13.7511 12.4779 14.4149 11.6545 14.4149V14.9149V15.4149C13.0269 15.4149 14.1426 14.3086 14.1544 12.9363L13.6544 12.932Z" fill="currentColor"/>`,
edit: `<path d="M17.0832 17.0807V17.5807H17.5832V17.0807H17.0832ZM2.9165 17.0807H2.4165V17.5807H2.9165V17.0807ZM2.9165 2.91406V2.41406H2.4165V2.91406H2.9165ZM9.58317 3.41406H10.0832V2.41406H9.58317V2.91406V3.41406ZM17.5832 10.4141V9.91406H16.5832V10.4141H17.0832H17.5832ZM6.24984 11.2474L5.89628 10.8938L5.74984 11.0403V11.2474H6.24984ZM6.24984 13.7474H5.74984V14.2474H6.24984V13.7474ZM8.74984 13.7474V14.2474H8.95694L9.10339 14.101L8.74984 13.7474ZM15.2082 2.28906L15.5617 1.93551L15.2082 1.58196L14.8546 1.93551L15.2082 2.28906ZM17.7082 4.78906L18.0617 5.14262L18.4153 4.78906L18.0617 4.43551L17.7082 4.78906ZM17.0832 17.0807V16.5807H2.9165V17.0807V17.5807H17.0832V17.0807ZM2.9165 17.0807H3.4165V2.91406H2.9165H2.4165V17.0807H2.9165ZM2.9165 2.91406V3.41406H9.58317V2.91406V2.41406H2.9165V2.91406ZM17.0832 10.4141H16.5832V17.0807H17.0832H17.5832V10.4141H17.0832ZM6.24984 11.2474H5.74984V13.7474H6.24984H6.74984V11.2474H6.24984ZM6.24984 13.7474V14.2474H8.74984V13.7474V13.2474H6.24984V13.7474ZM6.24984 11.2474L6.60339 11.6009L15.5617 2.64262L15.2082 2.28906L14.8546 1.93551L5.89628 10.8938L6.24984 11.2474ZM15.2082 2.28906L14.8546 2.64262L17.3546 5.14262L17.7082 4.78906L18.0617 4.43551L15.5617 1.93551L15.2082 2.28906ZM17.7082 4.78906L17.3546 4.43551L8.39628 13.3938L8.74984 13.7474L9.10339 14.101L18.0617 5.14262L17.7082 4.78906Z" fill="currentColor"/>`,
help: `<path d="M7.91683 7.91927V6.2526H12.0835V8.7526L10.0002 10.0026V12.0859M10.0002 13.7526V13.7609M17.9168 10.0026C17.9168 14.3749 14.3724 17.9193 10.0002 17.9193C5.62791 17.9193 2.0835 14.3749 2.0835 10.0026C2.0835 5.63035 5.62791 2.08594 10.0002 2.08594C14.3724 2.08594 17.9168 5.63035 17.9168 10.0026Z" stroke="currentColor" stroke-linecap="square"/>`,
"settings-gear": `<path d="M7.62516 4.46094L5.05225 3.86719L3.86475 5.05469L4.4585 7.6276L2.0835 9.21094V10.7943L4.4585 12.3776L3.86475 14.9505L5.05225 16.138L7.62516 15.5443L9.2085 17.9193H10.7918L12.3752 15.5443L14.9481 16.138L16.1356 14.9505L15.5418 12.3776L17.9168 10.7943V9.21094L15.5418 7.6276L16.1356 5.05469L14.9481 3.86719L12.3752 4.46094L10.7918 2.08594H9.2085L7.62516 4.46094Z" stroke="currentColor"/><path d="M12.5002 10.0026C12.5002 11.3833 11.3809 12.5026 10.0002 12.5026C8.61945 12.5026 7.50016 11.3833 7.50016 10.0026C7.50016 8.62189 8.61945 7.5026 10.0002 7.5026C11.3809 7.5026 12.5002 8.62189 12.5002 10.0026Z" stroke="currentColor"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {

View File

@@ -33,7 +33,7 @@
border-radius: 6px;
overflow: hidden;
background: var(--surface-base);
border: 1px solid var(--border-base);
border: 1px solid var(--border-weak-base);
transition: border-color 0.15s ease;
&:hover {
@@ -416,7 +416,30 @@
box-shadow: var(--shadow-xs-border-base);
background-color: var(--surface-raised-base);
overflow: visible;
overflow-anchor: none;
& > *:first-child {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
overflow: hidden;
}
& > *:last-child {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
overflow: hidden;
}
[data-component="collapsible"] {
border: none;
}
[data-component="card"] {
border: none;
}
}
&[data-permission="true"] {
&::before {
content: "";
position: absolute;
@@ -438,26 +461,11 @@
pointer-events: none;
z-index: -1;
}
}
& > *:first-child {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
overflow: hidden;
}
& > *:last-child {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
overflow: hidden;
}
[data-component="collapsible"] {
border: none;
}
[data-component="card"] {
border: none;
}
&[data-question="true"] {
background: var(--background-base);
border: 1px solid var(--border-weak-base);
}
}

View File

@@ -1,6 +1,7 @@
import { ComponentProps, For } from "solid-js"
const outerIndices = new Set([0, 1, 2, 3, 4, 7, 8, 11, 12, 13, 14, 15])
const outerIndices = new Set([1, 2, 4, 7, 8, 11, 13, 14])
const cornerIndices = new Set([0, 3, 12, 15])
const squares = Array.from({ length: 16 }, (_, i) => ({
id: i,
x: (i % 4) * 4,
@@ -8,11 +9,17 @@ const squares = Array.from({ length: 16 }, (_, i) => ({
delay: Math.random() * 1.5,
duration: 1 + Math.random() * 1,
outer: outerIndices.has(i),
corner: cornerIndices.has(i),
}))
export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
export function Spinner(props: {
class?: string
classList?: ComponentProps<"div">["classList"]
style?: ComponentProps<"div">["style"]
}) {
return (
<svg
{...props}
viewBox="0 0 15 15"
data-component="spinner"
classList={{
@@ -30,8 +37,11 @@ export function Spinner(props: { class?: string; classList?: ComponentProps<"div
height="3"
rx="1"
style={{
animation: `${square.outer ? "pulse-opacity-dim" : "pulse-opacity"} ${square.duration}s ease-in-out infinite`,
"animation-delay": `${square.delay}s`,
opacity: square.corner ? 0 : undefined,
animation: square.corner
? undefined
: `${square.outer ? "pulse-opacity-dim" : "pulse-opacity"} ${square.duration}s ease-in-out infinite`,
"animation-delay": square.corner ? undefined : `${square.delay}s`,
}}
/>
)}

View File

@@ -5,7 +5,7 @@
[data-slot="tooltip-keybind"] {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
}
[data-slot="tooltip-keybind-key"] {
@@ -18,11 +18,11 @@
[data-component="tooltip"] {
z-index: 1000;
max-width: 320px;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background-color: var(--surface-float-base);
color: var(--text-invert-strong);
background: var(--surface-float-base);
padding: 6px 12px;
padding: 2px 8px;
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md);

View File

@@ -383,7 +383,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
renderer: {
link({ href, title, text }) {
const titleAttr = title ? ` title="${title}"` : ""
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
},
},
},

View File

@@ -19,6 +19,7 @@
@import "../components/dropdown-menu.css" layer(components);
@import "../components/dialog.css" layer(components);
@import "../components/file-icon.css" layer(components);
@import "../components/hover-card.css" layer(components);
@import "../components/provider-icon.css" layer(components);
@import "../components/icon.css" layer(components);
@import "../components/icon-button.css" layer(components);

View File

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

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