mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-20 07:44:23 +00:00
Compare commits
197 Commits
v1.1.16
...
fix-markdo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83fba42e2f | ||
|
|
8cb0f199ee | ||
|
|
05fbf7eb78 | ||
|
|
5a8a0f6a56 | ||
|
|
37f30993fa | ||
|
|
ebc194ca9a | ||
|
|
4edb4fa4fa | ||
|
|
63176bb049 | ||
|
|
216a2d87cf | ||
|
|
dd1f981d23 | ||
|
|
bfc9b24b48 | ||
|
|
2691e1e666 | ||
|
|
3f16e0d89f | ||
|
|
994c55f709 | ||
|
|
2f32f2ceb5 | ||
|
|
076dfb3752 | ||
|
|
60aa0cb96e | ||
|
|
e5973e2860 | ||
|
|
dbd1987f0a | ||
|
|
f270ea65c5 | ||
|
|
1698448016 | ||
|
|
564d3edfac | ||
|
|
679270d9e0 | ||
|
|
9f66a45970 | ||
|
|
779610d668 | ||
|
|
1fb611ef0a | ||
|
|
972f5ecc7d | ||
|
|
8d720f9463 | ||
|
|
92931437c4 | ||
|
|
08ca1237cc | ||
|
|
6473e15793 | ||
|
|
16cac69a72 | ||
|
|
b2da41cfad | ||
|
|
fcf2da9571 | ||
|
|
253b7ea784 | ||
|
|
3a9fd1bb36 | ||
|
|
f84ac697dc | ||
|
|
76a79284d2 | ||
|
|
99a1e73fa1 | ||
|
|
ba4c86448b | ||
|
|
b36837ae93 | ||
|
|
e03932e586 | ||
|
|
6b019a125a | ||
|
|
6a2fed7042 | ||
|
|
74baae597a | ||
|
|
d78d31430d | ||
|
|
096e14d787 | ||
|
|
bbb3120b59 | ||
|
|
9e4438f5bf | ||
|
|
87438fb38e | ||
|
|
6b6d6e9e07 | ||
|
|
207a59aad4 | ||
|
|
b3ae1931fc | ||
|
|
4d08123ca0 | ||
|
|
7d3c7a9f65 | ||
|
|
50dfa9caf3 | ||
|
|
1f86aa8bb9 | ||
|
|
d83756eaaf | ||
|
|
c0b43d3cb4 | ||
|
|
3206ed47e0 | ||
|
|
346c5e0da6 | ||
|
|
5b431c36f8 | ||
|
|
44d24d42b8 | ||
|
|
3a9e6b558c | ||
|
|
9d92ae7530 | ||
|
|
e6e7eaf6e0 | ||
|
|
8ce5c2b900 | ||
|
|
78be8fecdc | ||
|
|
b5e9f96660 | ||
|
|
ad17e8d1f0 | ||
|
|
b75d4d1c5e | ||
|
|
cc67bc005d | ||
|
|
0ce849c3d5 | ||
|
|
6e13e2f74e | ||
|
|
9fd61aef6e | ||
|
|
bb3926bf45 | ||
|
|
b2b123a392 | ||
|
|
09ff3b9bb9 | ||
|
|
2256362ba2 | ||
|
|
077ca4454f | ||
|
|
05cbb11709 | ||
|
|
fcc561ebb7 | ||
|
|
ee6ca104e5 | ||
|
|
4347a77d89 | ||
|
|
76b10d85ee | ||
|
|
45a770cdb1 | ||
|
|
a57c8669b6 | ||
|
|
f9fcdead55 | ||
|
|
ff669d4414 | ||
|
|
9b2d595cfc | ||
|
|
3839d70a94 | ||
|
|
6fe265e7d8 | ||
|
|
2aed4d263b | ||
|
|
e2ac588c84 | ||
|
|
8917dfdf5e | ||
|
|
86900d71f5 | ||
|
|
adcc661798 | ||
|
|
f4a28b2659 | ||
|
|
a160a35d0c | ||
|
|
90eaf9b3fc | ||
|
|
16d516dbdb | ||
|
|
0026bc5815 | ||
|
|
bcdaf7e779 | ||
|
|
874e22a045 | ||
|
|
905226c01e | ||
|
|
73adf7e86f | ||
|
|
4c37e17ac2 | ||
|
|
cd6e07355b | ||
|
|
29703aee9a | ||
|
|
3997d3f2d7 | ||
|
|
1fccb3bda4 | ||
|
|
16b2bfa8ef | ||
|
|
4eb6b57503 | ||
|
|
7599396162 | ||
|
|
d99d1315ee | ||
|
|
d831432f93 | ||
|
|
0ddf8e6c6e | ||
|
|
a520c4ff98 | ||
|
|
a184714f67 | ||
|
|
9b76337236 | ||
|
|
4dc72669e5 | ||
|
|
dfa59dd21d | ||
|
|
f642a6c5b9 | ||
|
|
e37104cb10 | ||
|
|
dc654c93d2 | ||
|
|
c67b0a9ba4 | ||
|
|
5b699a0d9b | ||
|
|
bc557e828d | ||
|
|
fcaa041ef9 | ||
|
|
3c9d80d75f | ||
|
|
a761f66a16 | ||
|
|
15e80fca69 | ||
|
|
43680534df | ||
|
|
aa522aad62 | ||
|
|
82319bbd83 | ||
|
|
45fa4eda15 | ||
|
|
f242541ef3 | ||
|
|
562f067131 | ||
|
|
1ff46c75fa | ||
|
|
73d5cacc06 | ||
|
|
b8828f2609 | ||
|
|
2f7b2cf603 | ||
|
|
eaf18d9915 | ||
|
|
7aa7dd3690 | ||
|
|
bee4b6801e | ||
|
|
3565d8e44d | ||
|
|
0187b6bb72 | ||
|
|
0eb898abcf | ||
|
|
5a309c2dbf | ||
|
|
452f11ff77 | ||
|
|
774c24e76e | ||
|
|
ec4a44087b | ||
|
|
501347cda5 | ||
|
|
3f3816c0f2 | ||
|
|
0a3c72d678 | ||
|
|
66b7a4991e | ||
|
|
1550ae47c0 | ||
|
|
33ba064c40 | ||
|
|
96ae5925c3 | ||
|
|
3a750b0809 | ||
|
|
1258f7aeea | ||
|
|
797a56873d | ||
|
|
05867f9318 | ||
|
|
5947fe72e4 | ||
|
|
f3d4dd5099 | ||
|
|
b68a4a8838 | ||
|
|
b7a1d8f2f5 | ||
|
|
80e1173ef7 | ||
|
|
8ae10f1c94 | ||
|
|
f24251f89e | ||
|
|
3600bd27f4 | ||
|
|
92089bb295 | ||
|
|
a70932f742 | ||
|
|
217cf24c3c | ||
|
|
c87939ad12 | ||
|
|
a092f567b7 | ||
|
|
883a6577d5 | ||
|
|
20b52cad2a | ||
|
|
528291532b | ||
|
|
2b77a84c4f | ||
|
|
7d0b52dc29 | ||
|
|
c86c2acf4c | ||
|
|
29bf731d47 | ||
|
|
3c5a256f0f | ||
|
|
067338bc25 | ||
|
|
736cd10847 | ||
|
|
a03daa4252 | ||
|
|
b01eec38d1 | ||
|
|
35cb06e0e4 | ||
|
|
f3b7d2f786 | ||
|
|
1facf7d8e4 | ||
|
|
ddd9c71cca | ||
|
|
520a814fc2 | ||
|
|
2072c8681a | ||
|
|
21990621e2 | ||
|
|
68a0947292 | ||
|
|
f1f44644e2 |
24
.opencode/command/ai-deps.md
Normal file
24
.opencode/command/ai-deps.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: "Bump AI sdk dependencies minor / patch versions only"
|
||||
---
|
||||
|
||||
Please read @package.json and @packages/opencode/package.json.
|
||||
|
||||
Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes).
|
||||
|
||||
I want a report of every dependency and the version that can be upgraded to.
|
||||
What would be even better is if you can give me links to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added.
|
||||
|
||||
Consider using subagents for each dep to save your context window.
|
||||
|
||||
Here is a short list of some deps (please be comprehensive tho):
|
||||
|
||||
- "ai"
|
||||
- "@ai-sdk/openai"
|
||||
- "@ai-sdk/anthropic"
|
||||
- "@openrouter/ai-sdk-provider"
|
||||
- etc, etc
|
||||
|
||||
DO NOT upgrade the dependencies yet, just make a list of all dependencies and their versions that can be upgraded to minor or patch versions only.
|
||||
|
||||
Write up your findings to ai-sdk-updates.md
|
||||
@@ -1,4 +1,4 @@
|
||||
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
||||
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
|
||||
- To test opencode in `packages/opencode`, run `bun dev`.
|
||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
- the default branch in this repo is `dev`
|
||||
- The default branch in this repo is `dev`.
|
||||
|
||||
29
SECURITY.md
29
SECURITY.md
@@ -1,3 +1,32 @@
|
||||
# Security
|
||||
|
||||
## Threat Model
|
||||
|
||||
### Overview
|
||||
|
||||
OpenCode is an AI-powered coding assistant that runs locally on your machine. It provides an agent system with access to powerful tools including shell execution, file operations, and web access.
|
||||
|
||||
### No Sandbox
|
||||
|
||||
OpenCode does **not** sandbox the agent. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking - it prompts for confirmation before executing commands, writing files, etc. However, it is not designed to provide security isolation.
|
||||
|
||||
If you need true isolation, run OpenCode inside a Docker container or VM.
|
||||
|
||||
### Server Mode
|
||||
|
||||
Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server - any functionality it provides is not a vulnerability.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
| Category | Rationale |
|
||||
| ------------------------------- | ----------------------------------------------------------------------- |
|
||||
| **Server access when opted-in** | If you enable server mode, API access is expected behavior |
|
||||
| **Sandbox escapes** | The permission system is not a sandbox (see above) |
|
||||
| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies |
|
||||
| **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
|
||||
|
||||
---
|
||||
|
||||
# Reporting Security Issues
|
||||
|
||||
We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||
|
||||
2
STATS.md
2
STATS.md
@@ -199,3 +199,5 @@
|
||||
| 2026-01-10 | 2,632,023 (+188,458) | 1,503,670 (+34,219) | 4,135,693 (+222,677) |
|
||||
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
|
||||
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
|
||||
| 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) |
|
||||
| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) |
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
## Style Guide
|
||||
|
||||
- Try to keep things in one function unless composable or reusable
|
||||
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
|
||||
= obj` just reference it as obj.a and obj.b. this preserves context
|
||||
- AVOID `try`/`catch` where possible
|
||||
- AVOID using `any` type
|
||||
- PREFER single word variable names where possible
|
||||
- Use as many bun apis as possible like Bun.file()
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Prefer single word variable names where possible
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
|
||||
# Avoid let statements
|
||||
|
||||
we don't like let statements, especially combined with if/else statements.
|
||||
prefer const
|
||||
|
||||
This is bad:
|
||||
We don't like `let` statements, especially combined with if/else statements.
|
||||
Prefer `const`.
|
||||
|
||||
Good:
|
||||
|
||||
@@ -32,7 +29,7 @@ else foo = 2
|
||||
|
||||
# Avoid else statements
|
||||
|
||||
Prefer early returns or even using `iife` to avoid else statements
|
||||
Prefer early returns or using an `iife` to avoid else statements.
|
||||
|
||||
Good:
|
||||
|
||||
|
||||
99
bun.lock
99
bun.lock
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.16",
|
||||
"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.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -84,10 +84,12 @@
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@solidjs/start": "catalog:",
|
||||
"@stripe/stripe-js": "8.6.1",
|
||||
"chart.js": "4.5.1",
|
||||
"nitro": "3.0.1-alpha.1",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "0.3.0",
|
||||
"solid-stripe": "0.8.1",
|
||||
"vite": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -99,7 +101,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -126,7 +128,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -174,7 +176,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -203,7 +205,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -232,7 +234,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -248,7 +250,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -276,6 +278,7 @@
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.1.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -287,8 +290,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",
|
||||
@@ -351,7 +354,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -371,7 +374,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -382,7 +385,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -395,7 +398,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -435,7 +438,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -446,7 +449,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -502,7 +505,7 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.4",
|
||||
"@types/bun": "1.3.5",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
@@ -586,6 +589,10 @@
|
||||
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
|
||||
|
||||
"@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="],
|
||||
|
||||
"@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="],
|
||||
|
||||
"@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="],
|
||||
@@ -906,6 +913,10 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
|
||||
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="],
|
||||
@@ -1204,21 +1215,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=="],
|
||||
|
||||
@@ -1600,6 +1611,8 @@
|
||||
|
||||
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
|
||||
|
||||
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||
|
||||
"@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
|
||||
|
||||
"@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
|
||||
@@ -1652,6 +1665,8 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||
|
||||
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
|
||||
@@ -1758,7 +1773,7 @@
|
||||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
|
||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
@@ -2060,7 +2075,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
|
||||
|
||||
@@ -2318,6 +2333,10 @@
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="],
|
||||
|
||||
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
@@ -2540,6 +2559,10 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],
|
||||
|
||||
"graphql-request": ["graphql-request@6.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "cross-fetch": "^3.1.5" }, "peerDependencies": { "graphql": "14 - 16" } }, "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw=="],
|
||||
|
||||
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
|
||||
|
||||
"gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
|
||||
@@ -2768,6 +2791,8 @@
|
||||
|
||||
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
|
||||
|
||||
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
|
||||
|
||||
"iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="],
|
||||
|
||||
"iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="],
|
||||
@@ -2800,6 +2825,8 @@
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
@@ -3076,6 +3103,8 @@
|
||||
|
||||
"named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="],
|
||||
|
||||
"nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
@@ -3518,6 +3547,10 @@
|
||||
|
||||
"smol-toml": ["smol-toml@1.5.2", "", {}, "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ=="],
|
||||
|
||||
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
|
||||
|
||||
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
|
||||
|
||||
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
|
||||
@@ -3528,6 +3561,8 @@
|
||||
|
||||
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
||||
|
||||
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
||||
|
||||
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
@@ -3682,6 +3717,8 @@
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||
@@ -3874,6 +3911,8 @@
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
@@ -4024,6 +4063,8 @@
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
@@ -4266,6 +4307,8 @@
|
||||
|
||||
"editorconfig/minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="],
|
||||
|
||||
"engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"es-get-iterator/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1768178648,
|
||||
"narHash": "sha256-kz/F6mhESPvU1diB7tOM3nLcBfQe7GU7GQCymRlTi/s=",
|
||||
"lastModified": 1768395095,
|
||||
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3fbab70c6e69c87ea2b6e48aa6629da2aa6a23b0",
|
||||
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -81,12 +81,13 @@ This will walk you through installing the GitHub app, creating the workflow, and
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run opencode
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@latest
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
@@ -122,6 +122,7 @@ const ZEN_MODELS = [
|
||||
]
|
||||
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
|
||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||
properties: { value: auth.url.apply((url) => url!) },
|
||||
})
|
||||
@@ -177,6 +178,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
||||
//VITE_API_URL: gateway.url.apply((url) => url!),
|
||||
VITE_AUTH_URL: auth.url.apply((url) => url!),
|
||||
VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value,
|
||||
},
|
||||
transform: {
|
||||
server: {
|
||||
|
||||
2
install
2
install
@@ -369,7 +369,7 @@ case $current_shell in
|
||||
config_files="$HOME/.config/fish/config.fish"
|
||||
;;
|
||||
zsh)
|
||||
config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
|
||||
config_files="${ZDOTDIR:-$HOME}/.zshrc ${ZDOTDIR:-$HOME}/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
|
||||
;;
|
||||
bash)
|
||||
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-UCPTTk4b7d2bets7KgCeYBHWAUwUAPUyKm+xDYkSexE=",
|
||||
"aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ="
|
||||
"x86_64-linux": "sha256-XP1DXs1Fcfog99rjMryki9mMqn1g1H4ykHx7WDsnrnw=",
|
||||
"aarch64-darwin": "sha256-fupiqvXkW3Cl44K+n1cDz81vOboMXIHPHTey6TewX70="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.4",
|
||||
"@types/bun": "1.3.5",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -33,7 +33,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: { updaterEnabled?: boolean }
|
||||
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ import { useSync } from "@/context/sync"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
@@ -362,6 +364,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!isFocused()) setStore("popover", null)
|
||||
})
|
||||
|
||||
// Safety: reset composing state on focus change to prevent stuck state
|
||||
// This handles edge cases where compositionend event may not fire
|
||||
createEffect(() => {
|
||||
if (!isFocused()) setComposing(false)
|
||||
})
|
||||
|
||||
type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string }
|
||||
|
||||
const agentList = createMemo(() =>
|
||||
@@ -879,6 +887,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input
|
||||
// and should always insert a newline regardless of composition state
|
||||
if (event.key === "Enter" && event.shiftKey) {
|
||||
addPart({ type: "text", content: "\n", start: 0, end: 0 })
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && isImeComposing(event)) {
|
||||
return
|
||||
}
|
||||
@@ -942,11 +958,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && event.shiftKey) {
|
||||
addPart({ type: "text", content: "\n", start: 0, end: 0 })
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
// Note: Shift+Enter is handled earlier, before IME check
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
handleSubmit(event)
|
||||
}
|
||||
@@ -1560,6 +1572,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
fallback={
|
||||
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
|
||||
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
@@ -1569,6 +1584,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<ModelSelectorPopover>
|
||||
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
|
||||
<Button as="div" variant="ghost">
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
@@ -1583,10 +1601,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-text-base _hidden group-hover/prompt-input:inline-block"
|
||||
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
||||
onClick={() => local.model.variant.cycle()}
|
||||
>
|
||||
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
|
||||
{local.model.variant.current() ?? "Default"}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
|
||||
@@ -1,267 +1,203 @@
|
||||
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>
|
||||
<>
|
||||
<Show when={centerMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<button
|
||||
type="button"
|
||||
class="hidden md:flex w-[320px] h-7 px-1.5 items-center gap-2 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")}
|
||||
>
|
||||
<Icon name="magnifying-glass" size="small" class="text-text-weak" />
|
||||
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span>
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-md border border-border-weak-base bg-surface-base text-12-medium text-text-weak">
|
||||
{keybind()}
|
||||
</span>
|
||||
)}
|
||||
</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"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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())}
|
||||
</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()}
|
||||
>
|
||||
<Icon name="arrow-left" size="small" class="text-icon-base" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</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()}
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,9 +100,12 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const mod = await import("ghostty-web")
|
||||
ghostty = await mod.Ghostty.load()
|
||||
|
||||
const socket = new WebSocket(
|
||||
sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
|
||||
)
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
||||
if (window.__OPENCODE__?.serverPassword) {
|
||||
url.username = "opencode"
|
||||
url.password = window.__OPENCODE__?.serverPassword
|
||||
}
|
||||
const socket = new WebSocket(url)
|
||||
ws = socket
|
||||
|
||||
const t = new mod.Terminal({
|
||||
|
||||
115
packages/app/src/components/titlebar.tsx
Normal file
115
packages/app/src/components/titlebar.tsx
Normal 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"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type LspStatus,
|
||||
type VcsInfo,
|
||||
type PermissionRequest,
|
||||
type QuestionRequest,
|
||||
createOpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -37,6 +38,7 @@ type State = {
|
||||
config: Config
|
||||
path: Path
|
||||
session: Session[]
|
||||
sessionTotal: number
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
@@ -49,6 +51,9 @@ type State = {
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
}
|
||||
@@ -94,10 +99,12 @@ function createGlobalSync() {
|
||||
agent: [],
|
||||
command: [],
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
@@ -112,21 +119,32 @@ function createGlobalSync() {
|
||||
|
||||
async function loadSessions(directory: string) {
|
||||
const [store, setStore] = child(directory)
|
||||
globalSDK.client.session
|
||||
.list({ directory })
|
||||
const limit = store.limit
|
||||
|
||||
return globalSDK.client.session
|
||||
.list({ directory, roots: true })
|
||||
.then((x) => {
|
||||
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory))
|
||||
if (sandboxWorkspace) {
|
||||
setStore("session", reconcile(nonArchived, { key: "id" }))
|
||||
return
|
||||
}
|
||||
|
||||
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
||||
// Include up to the limit, plus any updated in the last 4 hours
|
||||
const sessions = nonArchived.filter((s, i) => {
|
||||
if (i < store.limit) return true
|
||||
if (i < limit) return true
|
||||
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
|
||||
return updated > fourHoursAgo
|
||||
})
|
||||
// Store total session count (used for "load more" pagination)
|
||||
setStore("sessionTotal", nonArchived.length)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -208,6 +226,38 @@ function createGlobalSync() {
|
||||
}
|
||||
})
|
||||
}),
|
||||
sdk.question.list().then((x) => {
|
||||
const grouped: Record<string, QuestionRequest[]> = {}
|
||||
for (const question of x.data ?? []) {
|
||||
if (!question?.id || !question.sessionID) continue
|
||||
const existing = grouped[question.sessionID]
|
||||
if (existing) {
|
||||
existing.push(question)
|
||||
continue
|
||||
}
|
||||
grouped[question.sessionID] = [question]
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions
|
||||
.filter((q) => !!q?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
@@ -396,6 +446,44 @@ function createGlobalSync() {
|
||||
)
|
||||
break
|
||||
}
|
||||
case "question.asked": {
|
||||
const sessionID = event.properties.sessionID
|
||||
const questions = store.question[sessionID]
|
||||
if (!questions) {
|
||||
setStore("question", sessionID, [event.properties])
|
||||
break
|
||||
}
|
||||
|
||||
const result = Binary.search(questions, event.properties.id, (q) => q.id)
|
||||
if (result.found) {
|
||||
setStore("question", sessionID, result.index, reconcile(event.properties))
|
||||
break
|
||||
}
|
||||
|
||||
setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const questions = store.question[event.properties.sessionID]
|
||||
if (!questions) break
|
||||
const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
|
||||
if (!result.found) break
|
||||
setStore(
|
||||
"question",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "lsp.updated": {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -5,6 +5,9 @@ export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: "web" | "desktop"
|
||||
|
||||
/** Desktop OS (Tauri only) */
|
||||
os?: "macos" | "windows" | "linux"
|
||||
|
||||
/** App version */
|
||||
version?: string
|
||||
|
||||
|
||||
@@ -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>>()
|
||||
|
||||
@@ -5,3 +5,7 @@
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
*[data-tauri-drag-region] {
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LocalProvider } from "@/context/local"
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
@@ -27,6 +28,11 @@ export default function Layout(props: ParentProps) {
|
||||
response: "once" | "always" | "reject"
|
||||
}) => sdk.client.permission.respond(input)
|
||||
|
||||
const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
|
||||
sdk.client.question.reply(input)
|
||||
|
||||
const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input)
|
||||
|
||||
const navigateToSession = (sessionID: string) => {
|
||||
navigate(`/${params.dir}/session/${sessionID}`)
|
||||
}
|
||||
@@ -36,6 +42,8 @@ export default function Layout(props: ParentProps) {
|
||||
data={sync.data}
|
||||
directory={directory()}
|
||||
onPermissionRespond={respond}
|
||||
onQuestionReply={replyToQuestion}
|
||||
onQuestionReject={rejectQuestion}
|
||||
onNavigateToSession={navigateToSession}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
|
||||
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vite start"
|
||||
},
|
||||
@@ -23,10 +23,12 @@
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@solidjs/start": "catalog:",
|
||||
"@stripe/stripe-js": "8.6.1",
|
||||
"chart.js": "4.5.1",
|
||||
"nitro": "3.0.1-alpha.1",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "0.3.0",
|
||||
"solid-stripe": "0.8.1",
|
||||
"vite": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
1
packages/console/app/public/social-share-black.png
Symbolic link
1
packages/console/app/public/social-share-black.png
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../ui/src/assets/images/social-share-black.png
|
||||
@@ -24,6 +24,9 @@ export function Footer() {
|
||||
<div data-slot="cell">
|
||||
<a href="/docs">Docs</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<a href="/changelog">Changelog</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<a href="/discord">Discord</a>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuthSession } from "~/context/auth"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const url = new URL(input.request.url)
|
||||
|
||||
try {
|
||||
const code = url.searchParams.get("code")
|
||||
if (!code) throw new Error("No code found")
|
||||
@@ -27,7 +28,7 @@ export async function GET(input: APIEvent) {
|
||||
current: id,
|
||||
}
|
||||
})
|
||||
return redirect("/auth")
|
||||
return redirect(url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", ""))
|
||||
} catch (e: any) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -2,6 +2,9 @@ import type { APIEvent } from "@solidjs/start/server"
|
||||
import { AuthClient } from "~/context/auth"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
|
||||
const url = new URL(input.request.url)
|
||||
const cont = url.searchParams.get("continue") ?? ""
|
||||
const callbackUrl = new URL(`./callback${cont}`, input.request.url)
|
||||
const result = await AuthClient.authorize(callbackUrl.toString(), "code")
|
||||
return Response.redirect(result.url, 302)
|
||||
}
|
||||
|
||||
805
packages/console/app/src/routes/black.css
Normal file
805
packages/console/app/src/routes/black.css
Normal file
@@ -0,0 +1,805 @@
|
||||
[data-page="black"] {
|
||||
background: #000;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
font-family: var(--font-mono);
|
||||
color: #fff;
|
||||
|
||||
[data-component="header-gradient"] {
|
||||
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%);
|
||||
}
|
||||
|
||||
[data-component="header"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-component="content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
|
||||
[data-slot="hero"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
margin-top: 40px;
|
||||
padding: 0 20px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="hero-black"] {
|
||||
margin-top: 40px;
|
||||
padding: 0 20px;
|
||||
|
||||
@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));
|
||||
mask-image: linear-gradient(to bottom, black, transparent);
|
||||
stroke-width: 1.5;
|
||||
|
||||
[data-slot="black-fill"] {
|
||||
fill: url(#hero-black-fill-gradient);
|
||||
}
|
||||
|
||||
[data-slot="black-stroke"] {
|
||||
fill: url(#hero-black-stroke-gradient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="cta"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-top: -32px;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: -16px;
|
||||
}
|
||||
|
||||
[data-slot="heading"] {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="subheading"] {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 15px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 18px;
|
||||
line-height: 160%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="button"] {
|
||||
display: inline-flex;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
|
||||
&:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
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%;
|
||||
}
|
||||
|
||||
[data-slot="follow-us"] {
|
||||
display: inline-flex;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-slot="pricing"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-slot="pricing-card"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
background: #000;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
transition: border-color 200ms ease;
|
||||
|
||||
&:hover:not([data-selected="true"]) {
|
||||
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"] {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="price"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
line-height: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot="amount"] {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-slot="content"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="period"],
|
||||
[data-slot="multiplier"] {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
}
|
||||
|
||||
[data-slot="billing"] {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-slot="multiplier"] {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
|
||||
&::before {
|
||||
content: "·";
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="terms"] {
|
||||
list-style: none;
|
||||
padding: 0 24px 24px 24px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
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;
|
||||
|
||||
&::before {
|
||||
content: "▪";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="actions"] {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="continue"] {
|
||||
background: rgb(255, 255, 255);
|
||||
color: rgb(0, 0, 0);
|
||||
|
||||
&:hover {
|
||||
background: rgb(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="fine-print"] {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Subscribe page styles */
|
||||
[data-slot="subscribe-form"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
margin-top: -18px;
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
padding: 0 20px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-slot="form-card"] {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
border-radius: 4px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
[data-slot="plan-header"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="title"] {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
[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: 13px;
|
||||
|
||||
&::before {
|
||||
content: "·";
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="divider"] {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.17);
|
||||
}
|
||||
|
||||
[data-slot="section-title"] {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 16px;
|
||||
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;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
[data-slot="error"] {
|
||||
color: #ff6b6b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-slot="submit-button"] {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #000;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="charge-notice"] {
|
||||
color: #d4a500;
|
||||
font-size: 14px;
|
||||
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;
|
||||
padding: 40px 0;
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="fine-print"] {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="workspace-picker"] {
|
||||
[data-slot="workspace-list"] {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
max-height: 240px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="workspace-item"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
cursor: pointer;
|
||||
|
||||
[data-slot="selected-icon"] {
|
||||
visibility: hidden;
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-active="true"] {
|
||||
background: #161616;
|
||||
|
||||
[data-slot="selected-icon"] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="footer"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
[data-slot="footer-content"] {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
span,
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-slot="github-stars"] {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
[data-slot="anomaly"] {
|
||||
display: none;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="anomaly-alt"] {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
text-decoration: none;
|
||||
margin-bottom: 24px;
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: "JetBrains Mono Nerd Font", monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::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;
|
||||
}
|
||||
}
|
||||
174
packages/console/app/src/routes/black.tsx
Normal file
174
packages/console/app/src/routes/black.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { A, createAsync, RouteSectionProps } from "@solidjs/router"
|
||||
import { Title, Meta, Link } from "@solidjs/meta"
|
||||
import { createMemo } from "solid-js"
|
||||
import { github } from "~/lib/github"
|
||||
import { config } from "~/config"
|
||||
import "./black.css"
|
||||
|
||||
export default function BlackLayout(props: RouteSectionProps) {
|
||||
const githubData = createAsync(() => github())
|
||||
const starCount = createMemo(() =>
|
||||
githubData()?.stars
|
||||
? new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(githubData()!.stars!)
|
||||
: config.github.starsFormatted.compact,
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-page="black">
|
||||
<Title>OpenCode Black | Access all the world's best coding models</Title>
|
||||
<Meta
|
||||
name="description"
|
||||
content="Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans."
|
||||
/>
|
||||
<Link rel="canonical" href={`${config.baseUrl}/black`} />
|
||||
<Meta property="og:type" content="website" />
|
||||
<Meta property="og:url" content={`${config.baseUrl}/black`} />
|
||||
<Meta property="og:title" content="OpenCode Black | Access all the world's best coding models" />
|
||||
<Meta
|
||||
property="og:description"
|
||||
content="Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans."
|
||||
/>
|
||||
<Meta property="og:image" content="/social-share-black.png" />
|
||||
<Meta name="twitter:card" content="summary_large_image" />
|
||||
<Meta name="twitter:title" content="OpenCode Black | Access all the world's best coding models" />
|
||||
<Meta
|
||||
name="twitter:description"
|
||||
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" />
|
||||
<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">
|
||||
<title>opencode</title>
|
||||
<g clip-path="url(#clip0_3654_210259)">
|
||||
<mask
|
||||
id="mask0_3654_210259"
|
||||
style="mask-type:luminance"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="179"
|
||||
height="32"
|
||||
>
|
||||
<path d="M178.286 0H0V32H178.286V0Z" fill="white" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_3654_210259)">
|
||||
<path d="M13.7132 22.8577H4.57031V13.7148H13.7132V22.8577Z" fill="#444444" />
|
||||
<path
|
||||
d="M13.7143 9.14174H4.57143V22.856H13.7143V9.14174ZM18.2857 27.4275H0V4.57031H18.2857V27.4275Z"
|
||||
fill="#CDCDCD"
|
||||
/>
|
||||
<path d="M36.5725 22.8577H27.4297V13.7148H36.5725V22.8577Z" fill="#444444" />
|
||||
<path
|
||||
d="M27.4308 22.856H36.5737V9.14174H27.4308V22.856ZM41.1451 27.4275H27.4308V31.9989H22.8594V4.57031H41.1451V27.4275Z"
|
||||
fill="#CDCDCD"
|
||||
/>
|
||||
<path d="M64.0033 18.2852V22.8566H50.2891V18.2852H64.0033Z" fill="#444444" />
|
||||
<path
|
||||
d="M63.9967 18.2846H50.2824V22.856H63.9967V27.4275H45.7109V4.57031H63.9967V18.2846ZM50.2824 13.7132H59.4252V9.14174H50.2824V13.7132Z"
|
||||
fill="#CDCDCD"
|
||||
/>
|
||||
<path d="M82.2835 27.4291H73.1406V13.7148H82.2835V27.4291Z" fill="#444444" />
|
||||
<path
|
||||
d="M82.2846 9.14174H73.1417V27.4275H68.5703V4.57031H82.2846V9.14174ZM86.856 27.4275H82.2846V9.14174H86.856V27.4275Z"
|
||||
fill="#CDCDCD"
|
||||
/>
|
||||
<path d="M109.714 22.8577H96V13.7148H109.714V22.8577Z" fill="#444444" />
|
||||
<path
|
||||
d="M109.715 9.14174H96.0011V22.856H109.715V27.4275H91.4297V4.57031H109.715V9.14174Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M128.002 22.8577H118.859V13.7148H128.002V22.8577Z" fill="#444444" />
|
||||
<path
|
||||
d="M128.003 9.14174H118.86V22.856H128.003V9.14174ZM132.575 27.4275H114.289V4.57031H132.575V27.4275Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M150.854 22.8577H141.711V13.7148H150.854V22.8577Z" fill="#444444" />
|
||||
<path
|
||||
d="M150.855 9.14286H141.712V22.8571H150.855V9.14286ZM155.426 27.4286H137.141V4.57143H150.855V0H155.426V27.4286Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M178.285 18.2852V22.8566H164.57V18.2852H178.285Z" fill="#444444" />
|
||||
<path
|
||||
d="M164.571 9.14174V13.7132H173.714V9.14174H164.571ZM178.286 18.2846H164.571V22.856H178.286V27.4275H160V4.57031H178.286V18.2846Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3654_210259">
|
||||
<rect width="178.286" height="32" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</A>
|
||||
</header>
|
||||
<main data-component="content">
|
||||
<div data-slot="hero">
|
||||
<h1>Access all the world's best coding models</h1>
|
||||
<p>Including Claude, GPT, Gemini and more</p>
|
||||
</div>
|
||||
<div data-slot="hero-black">
|
||||
<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"
|
||||
x1="290.82"
|
||||
y1="1.57422"
|
||||
x2="290.82"
|
||||
y2="87.0326"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="var(--hero-black-fill-from)" />
|
||||
<stop offset="1" stop-color="var(--hero-black-fill-to)" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="hero-black-stroke-gradient"
|
||||
x1="290.82"
|
||||
y1="2.03255"
|
||||
x2="290.82"
|
||||
y2="87.0325"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="var(--hero-black-stroke-from)" />
|
||||
<stop offset="1" stop-color="var(--hero-black-stroke-to)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
{props.children}
|
||||
</main>
|
||||
<footer data-component="footer">
|
||||
<div data-slot="footer-content">
|
||||
<span data-slot="anomaly">
|
||||
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
|
||||
</span>
|
||||
<a href={config.github.repoUrl} target="_blank">
|
||||
GitHub <span data-slot="github-stars">[{starCount()}]</span>
|
||||
</a>
|
||||
<a href="/docs">Docs</a>
|
||||
<span>
|
||||
<A href="/legal/privacy-policy">Privacy</A>
|
||||
</span>
|
||||
<span>
|
||||
<A href="/legal/terms-of-service">Terms</A>
|
||||
</span>
|
||||
</div>
|
||||
<span data-slot="anomaly-alt">
|
||||
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
packages/console/app/src/routes/black/common.tsx
Normal file
62
packages/console/app/src/routes/black/common.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Match, Switch } from "solid-js"
|
||||
|
||||
export const plans = [
|
||||
{ id: "20", multiplier: null },
|
||||
{ id: "100", multiplier: "5x more usage than Black 20" },
|
||||
{ id: "200", multiplier: "20x more usage than Black 20" },
|
||||
] as const
|
||||
|
||||
export type PlanID = (typeof plans)[number]["id"]
|
||||
export type Plan = (typeof plans)[number]
|
||||
|
||||
export function PlanIcon(props: { plan: string }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.plan === "20"}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Black 20 plan</title>
|
||||
<rect x="0.5" y="0.5" width="23" height="23" stroke="currentColor" />
|
||||
</svg>
|
||||
</Match>
|
||||
<Match when={props.plan === "100"}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Black 100 plan</title>
|
||||
<rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" />
|
||||
<rect x="0.5" y="14.5" width="9" height="9" stroke="currentColor" />
|
||||
<rect x="14.5" y="0.5" width="9" height="9" stroke="currentColor" />
|
||||
<rect x="14.5" y="14.5" width="9" height="9" stroke="currentColor" />
|
||||
</svg>
|
||||
</Match>
|
||||
<Match when={props.plan === "200"}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Black 200 plan</title>
|
||||
<rect x="0.5" y="0.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="0.5" y="5.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="0.5" y="10.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="0.5" y="15.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="0.5" y="20.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="5.5" y="0.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="5.5" y="5.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="5.5" y="10.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="5.5" y="15.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="5.5" y="20.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="10.5" y="0.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="10.5" y="5.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="10.5" y="10.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="10.5" y="15.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="10.5" y="20.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="15.5" y="0.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="15.5" y="5.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="15.5" y="10.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="15.5" y="15.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="15.5" y="20.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="20.5" y="0.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="20.5" y="5.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="20.5" y="10.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="20.5" y="15.5" width="3" height="3" stroke="currentColor" />
|
||||
<rect x="20.5" y="20.5" width="3" height="3" stroke="currentColor" />
|
||||
</svg>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
[data-page="black"] {
|
||||
background: #000;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
font-family: var(--font-mono);
|
||||
color: #fff;
|
||||
|
||||
[data-component="header-gradient"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 288px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
}
|
||||
|
||||
[data-component="header"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 40px;
|
||||
flex-shrink: 0;
|
||||
|
||||
/* [data-component="header-logo"] { */
|
||||
/* } */
|
||||
}
|
||||
|
||||
[data-component="content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
|
||||
[data-slot="hero-black"] {
|
||||
margin-top: 110px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="cta"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-top: -18px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
[data-slot="heading"] {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%; /* 28.8px */
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
[data-slot="subheading"] {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 15px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 18px;
|
||||
line-height: 160%;
|
||||
}
|
||||
}
|
||||
[data-slot="button"] {
|
||||
display: inline-flex;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
font-family: "JetBrains Mono Nerd Font";
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
|
||||
&:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
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%; /* 20.8px */
|
||||
}
|
||||
[data-slot="follow-us"] {
|
||||
display: inline-flex;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-family: "JetBrains Mono Nerd Font";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-slot="pricing"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
padding: 0 20px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="pricing-card"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
[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: "·";
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="selected-plan"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 40px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
[data-slot="selected-card"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
|
||||
[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: "·";
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="terms"] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
|
||||
li {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
&::before {
|
||||
content: "▪";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="actions"] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
|
||||
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="cancel"] {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="continue"] {
|
||||
background: rgba(255, 255, 255, 0.17);
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="fine-print"] {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%; /* 20.8px */
|
||||
font-style: italic;
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="footer"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
[data-slot="footer-content"] {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
span,
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: "JetBrains Mono Nerd Font";
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-slot="github-stars"] {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-family: "JetBrains Mono Nerd Font";
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
[data-slot="anomaly"] {
|
||||
display: none;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
[data-slot="anomaly-alt"] {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: "JetBrains Mono Nerd Font";
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
text-decoration: none;
|
||||
margin-bottom: 24px;
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: "JetBrains Mono Nerd Font";
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
451
packages/console/app/src/routes/black/subscribe/[plan].tsx
Normal file
451
packages/console/app/src/routes/black/subscribe/[plan].tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import { A, createAsync, query, redirect, useParams } from "@solidjs/router"
|
||||
import { Title } from "@solidjs/meta"
|
||||
import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js"
|
||||
import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe"
|
||||
import { PlanID, plans } from "../common"
|
||||
import { getActor, useAuthSession } from "~/context/auth"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { and, Database, 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"
|
||||
import { createList } from "solid-list"
|
||||
import { Modal } from "~/component/modal"
|
||||
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
|
||||
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
|
||||
|
||||
const getWorkspaces = query(async (plan: string) => {
|
||||
"use server"
|
||||
const actor = await getActor()
|
||||
if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe/" + plan)
|
||||
return withActor(async () => {
|
||||
return Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: WorkspaceTable.id,
|
||||
name: WorkspaceTable.name,
|
||||
slug: WorkspaceTable.slug,
|
||||
billing: {
|
||||
customerID: BillingTable.customerID,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
paymentMethodType: BillingTable.paymentMethodType,
|
||||
paymentMethodLast4: BillingTable.paymentMethodLast4,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
timeSubscriptionBooked: BillingTable.timeSubscriptionBooked,
|
||||
},
|
||||
})
|
||||
.from(UserTable)
|
||||
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
|
||||
.innerJoin(BillingTable, eq(WorkspaceTable.id, BillingTable.workspaceID))
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.accountID, Actor.account()),
|
||||
isNull(WorkspaceTable.timeDeleted),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}, "black.subscribe.workspaces")
|
||||
|
||||
const createSetupIntent = async (input: { plan: string; workspaceID: string }) => {
|
||||
"use server"
|
||||
const { plan, workspaceID } = input
|
||||
|
||||
if (!plan || !["20", "100", "200"].includes(plan)) return { error: "Invalid plan" }
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
|
||||
return withActor(async () => {
|
||||
const session = await useAuthSession()
|
||||
const account = session.data.account?.[session.data.current ?? ""]
|
||||
const email = account?.email
|
||||
|
||||
const customer = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (customer?.subscriptionID) {
|
||||
return { error: "This workspace already has a subscription" }
|
||||
}
|
||||
|
||||
let customerID = customer?.customerID
|
||||
if (!customerID) {
|
||||
const customer = await Billing.stripe().customers.create({
|
||||
email,
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
customerID = customer.id
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
)
|
||||
}
|
||||
|
||||
const intent = await Billing.stripe().setupIntents.create({
|
||||
customer: customerID,
|
||||
payment_method_types: ["card"],
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
|
||||
return { clientSecret: intent.client_secret ?? undefined }
|
||||
}, workspaceID)
|
||||
}
|
||||
|
||||
const bookSubscription = async (input: {
|
||||
workspaceID: string
|
||||
plan: PlanID
|
||||
paymentMethodID: string
|
||||
paymentMethodType: string
|
||||
paymentMethodLast4?: string
|
||||
}) => {
|
||||
"use server"
|
||||
return withActor(
|
||||
() =>
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
paymentMethodID: input.paymentMethodID,
|
||||
paymentMethodType: input.paymentMethodType,
|
||||
paymentMethodLast4: input.paymentMethodLast4,
|
||||
subscriptionPlan: input.plan,
|
||||
timeSubscriptionBooked: new Date(),
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, input.workspaceID)),
|
||||
),
|
||||
input.workspaceID,
|
||||
)
|
||||
}
|
||||
|
||||
interface SuccessData {
|
||||
plan: string
|
||||
paymentMethodType: string
|
||||
paymentMethodLast4?: string
|
||||
}
|
||||
|
||||
function Failure(props: { message: string }) {
|
||||
return (
|
||||
<div data-slot="failure">
|
||||
<p data-slot="message">Uh oh! {props.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Success(props: SuccessData) {
|
||||
return (
|
||||
<div data-slot="success">
|
||||
<p data-slot="title">You're on the OpenCode Black waitlist</p>
|
||||
<dl data-slot="details">
|
||||
<div>
|
||||
<dt>Subscription plan</dt>
|
||||
<dd>OpenCode Black {props.plan}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Amount</dt>
|
||||
<dd>${props.plan} per month</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Payment method</dt>
|
||||
<dd>
|
||||
<Show when={props.paymentMethodLast4} fallback={<span>{props.paymentMethodType}</span>}>
|
||||
<span>
|
||||
{props.paymentMethodType} - {props.paymentMethodLast4}
|
||||
</span>
|
||||
</Show>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Date joined</dt>
|
||||
<dd>{new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p data-slot="charge-notice">Your card will be charged when your subscription is activated</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) {
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
const [error, setError] = createSignal<string | undefined>(undefined)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
if (!stripe() || !elements()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(undefined)
|
||||
|
||||
const result = await elements()!.submit()
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? "An error occurred")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({
|
||||
elements: elements()!,
|
||||
confirmParams: {
|
||||
expand: ["payment_method"],
|
||||
payment_method_data: {
|
||||
allow_redisplay: "always",
|
||||
},
|
||||
},
|
||||
redirect: "if_required",
|
||||
})
|
||||
|
||||
if (confirmError) {
|
||||
setError(confirmError.message ?? "An error occurred")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO
|
||||
console.log(setupIntent)
|
||||
if (setupIntent?.status === "succeeded") {
|
||||
const pm = setupIntent.payment_method as PaymentMethod
|
||||
|
||||
await bookSubscription({
|
||||
workspaceID: props.workspaceID,
|
||||
plan: props.plan,
|
||||
paymentMethodID: pm.id,
|
||||
paymentMethodType: pm.type,
|
||||
paymentMethodLast4: pm.card?.last4,
|
||||
})
|
||||
|
||||
props.onSuccess({
|
||||
plan: props.plan,
|
||||
paymentMethodType: pm.type,
|
||||
paymentMethodLast4: pm.card?.last4,
|
||||
})
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} data-slot="checkout-form">
|
||||
<PaymentElement />
|
||||
<AddressElement options={{ mode: "billing" }} />
|
||||
<Show when={error()}>
|
||||
<p data-slot="error">{error()}</p>
|
||||
</Show>
|
||||
<button type="submit" disabled={loading() || !stripe() || !elements()} data-slot="submit-button">
|
||||
{loading() ? "Processing..." : `Subscribe $${props.plan}`}
|
||||
</button>
|
||||
<p data-slot="charge-notice">You will only be charged when your subscription is activated</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BlackSubscribe() {
|
||||
const params = useParams()
|
||||
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
|
||||
const plan = planData.id
|
||||
|
||||
const workspaces = createAsync(() => getWorkspaces(plan))
|
||||
const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined)
|
||||
const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined)
|
||||
const [failure, setFailure] = createSignal<string | undefined>(undefined)
|
||||
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
|
||||
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
|
||||
|
||||
// Resolve stripe promise once
|
||||
createEffect(() => {
|
||||
stripePromise.then((s) => {
|
||||
if (s) setStripe(s)
|
||||
})
|
||||
})
|
||||
|
||||
// Auto-select if only one workspace
|
||||
createEffect(() => {
|
||||
const ws = workspaces()
|
||||
if (ws?.length === 1 && !selectedWorkspace()) {
|
||||
setSelectedWorkspace(ws[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch setup intent when workspace is selected (unless workspace already has payment method)
|
||||
createEffect(async () => {
|
||||
const id = selectedWorkspace()
|
||||
if (!id) return
|
||||
|
||||
const ws = workspaces()?.find((w) => w.id === id)
|
||||
if (ws?.billing?.subscriptionID) {
|
||||
setFailure("This workspace already has a subscription")
|
||||
return
|
||||
}
|
||||
if (ws?.billing?.paymentMethodID) {
|
||||
if (!ws?.billing?.timeSubscriptionBooked) {
|
||||
await bookSubscription({
|
||||
workspaceID: id,
|
||||
plan: planData.id,
|
||||
paymentMethodID: ws.billing.paymentMethodID!,
|
||||
paymentMethodType: ws.billing.paymentMethodType!,
|
||||
paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
|
||||
})
|
||||
}
|
||||
setSuccess({
|
||||
plan: planData.id,
|
||||
paymentMethodType: ws.billing.paymentMethodType!,
|
||||
paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await createSetupIntent({ plan, workspaceID: id })
|
||||
if (result.error) {
|
||||
setFailure(result.error)
|
||||
} else if ("clientSecret" in result) {
|
||||
setClientSecret(result.clientSecret)
|
||||
}
|
||||
})
|
||||
|
||||
// Keyboard navigation for workspace picker
|
||||
const { active, setActive, onKeyDown } = createList({
|
||||
items: () => workspaces()?.map((w) => w.id) ?? [],
|
||||
initialActive: null,
|
||||
})
|
||||
|
||||
const handleSelectWorkspace = (id: string) => {
|
||||
setSelectedWorkspace(id)
|
||||
}
|
||||
|
||||
let listRef: HTMLUListElement | undefined
|
||||
|
||||
// Show workspace picker if multiple workspaces and none selected
|
||||
const showWorkspacePicker = () => {
|
||||
const ws = workspaces()
|
||||
return ws && ws.length > 1 && !selectedWorkspace()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Subscribe to OpenCode Black</Title>
|
||||
<section data-slot="subscribe-form">
|
||||
<div data-slot="form-card">
|
||||
<Switch>
|
||||
<Match when={success()}>{(data) => <Success {...data()} />}</Match>
|
||||
<Match when={failure()}>{(data) => <Failure message={data()} />}</Match>
|
||||
<Match when={true}>
|
||||
<>
|
||||
<div data-slot="plan-header">
|
||||
<p data-slot="title">Subscribe to OpenCode Black</p>
|
||||
<p data-slot="price">
|
||||
<span data-slot="amount">${planData.id}</span> <span data-slot="period">per month</span>
|
||||
<Show when={planData.multiplier}>
|
||||
<span data-slot="multiplier">{planData.multiplier}</span>
|
||||
</Show>
|
||||
</p>
|
||||
</div>
|
||||
<div data-slot="divider" />
|
||||
<p data-slot="section-title">Payment method</p>
|
||||
|
||||
<Show
|
||||
when={clientSecret() && selectedWorkspace() && stripe()}
|
||||
fallback={
|
||||
<div data-slot="loading">
|
||||
<p>{selectedWorkspace() ? "Loading payment form..." : "Select a workspace to continue"}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Elements
|
||||
stripe={stripe()!}
|
||||
options={{
|
||||
clientSecret: clientSecret()!,
|
||||
appearance: {
|
||||
theme: "night",
|
||||
variables: {
|
||||
colorPrimary: "#ffffff",
|
||||
colorBackground: "#1a1a1a",
|
||||
colorText: "#ffffff",
|
||||
colorTextSecondary: "#999999",
|
||||
colorDanger: "#ff6b6b",
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
borderRadius: "4px",
|
||||
spacingUnit: "4px",
|
||||
},
|
||||
rules: {
|
||||
".Input": {
|
||||
backgroundColor: "#1a1a1a",
|
||||
border: "1px solid rgba(255, 255, 255, 0.17)",
|
||||
color: "#ffffff",
|
||||
},
|
||||
".Input:focus": {
|
||||
borderColor: "rgba(255, 255, 255, 0.35)",
|
||||
boxShadow: "none",
|
||||
},
|
||||
".Label": {
|
||||
color: "rgba(255, 255, 255, 0.59)",
|
||||
fontSize: "14px",
|
||||
marginBottom: "8px",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IntentForm plan={plan} workspaceID={selectedWorkspace()!} onSuccess={setSuccess} />
|
||||
</Elements>
|
||||
</Show>
|
||||
</>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
{/* Workspace picker modal */}
|
||||
<Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title="Select a workspace for this plan">
|
||||
<div data-slot="workspace-picker">
|
||||
<ul
|
||||
ref={listRef}
|
||||
data-slot="workspace-list"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && active()) {
|
||||
handleSelectWorkspace(active()!)
|
||||
} else {
|
||||
onKeyDown(e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<li
|
||||
data-slot="workspace-item"
|
||||
data-active={active() === workspace.id}
|
||||
onMouseEnter={() => setActive(workspace.id)}
|
||||
onClick={() => handleSelectWorkspace(workspace.id)}
|
||||
>
|
||||
<span data-slot="selected-icon">[*]</span>
|
||||
<span>{workspace.name || workspace.slug}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Modal>
|
||||
<p data-slot="fine-print">
|
||||
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
477
packages/console/app/src/routes/changelog/index.css
Normal file
477
packages/console/app/src/routes/changelog/index.css
Normal file
@@ -0,0 +1,477 @@
|
||||
::selection {
|
||||
background: var(--color-background-interactive);
|
||||
color: var(--color-text-strong);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: var(--color-background-interactive);
|
||||
color: var(--color-text-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="changelog"] {
|
||||
--color-background: hsl(0, 20%, 99%);
|
||||
--color-background-weak: hsl(0, 8%, 97%);
|
||||
--color-background-weak-hover: hsl(0, 8%, 94%);
|
||||
--color-background-strong: hsl(0, 5%, 12%);
|
||||
--color-background-strong-hover: hsl(0, 5%, 18%);
|
||||
--color-background-interactive: hsl(62, 84%, 88%);
|
||||
--color-background-interactive-weaker: hsl(64, 74%, 95%);
|
||||
|
||||
--color-text: hsl(0, 1%, 39%);
|
||||
--color-text-weak: hsl(0, 1%, 60%);
|
||||
--color-text-weaker: hsl(30, 2%, 81%);
|
||||
--color-text-strong: hsl(0, 5%, 12%);
|
||||
--color-text-inverted: hsl(0, 20%, 99%);
|
||||
|
||||
--color-border: hsl(30, 2%, 81%);
|
||||
--color-border-weak: hsl(0, 1%, 85%);
|
||||
|
||||
--color-icon: hsl(0, 1%, 55%);
|
||||
|
||||
background: var(--color-background);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
padding-bottom: 5rem;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--color-background: hsl(0, 9%, 7%);
|
||||
--color-background-weak: hsl(0, 6%, 10%);
|
||||
--color-background-weak-hover: hsl(0, 6%, 15%);
|
||||
--color-background-strong: hsl(0, 15%, 94%);
|
||||
--color-background-strong-hover: hsl(0, 15%, 97%);
|
||||
--color-background-interactive: hsl(62, 100%, 90%);
|
||||
--color-background-interactive-weaker: hsl(60, 20%, 8%);
|
||||
|
||||
--color-text: hsl(0, 4%, 71%);
|
||||
--color-text-weak: hsl(0, 2%, 49%);
|
||||
--color-text-weaker: hsl(0, 3%, 28%);
|
||||
--color-text-strong: hsl(0, 15%, 94%);
|
||||
--color-text-inverted: hsl(0, 9%, 7%);
|
||||
|
||||
--color-border: hsl(0, 3%, 28%);
|
||||
--color-border-weak: hsl(0, 4%, 23%);
|
||||
|
||||
--color-icon: hsl(10, 3%, 43%);
|
||||
}
|
||||
|
||||
/* Header styles - copied from download */
|
||||
[data-component="top"] {
|
||||
padding: 24px 5rem;
|
||||
height: 80px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--color-background);
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
z-index: 10;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 24px 1.5rem;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 34px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
[data-component="nav-desktop"] {
|
||||
ul {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
gap: 24px;
|
||||
}
|
||||
li {
|
||||
display: inline-block;
|
||||
a {
|
||||
text-decoration: none;
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
[data-slot="cta-button"] {
|
||||
background: var(--color-background-strong);
|
||||
color: var(--color-text-inverted);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
[data-slot="cta-button"]:hover {
|
||||
background: var(--color-background-strong-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="nav-mobile"] {
|
||||
button > svg {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-toggle"] {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
cursor: pointer;
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-toggle"]:hover {
|
||||
background: var(--color-background-weak);
|
||||
}
|
||||
|
||||
[data-component="nav-mobile"] {
|
||||
display: none;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
display: block;
|
||||
|
||||
[data-component="nav-mobile-icon"] {
|
||||
cursor: pointer;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-menu-list"] {
|
||||
position: fixed;
|
||||
background: var(--color-background);
|
||||
top: 80px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 20px 0;
|
||||
|
||||
li {
|
||||
a {
|
||||
text-decoration: none;
|
||||
padding: 20px;
|
||||
display: block;
|
||||
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: var(--color-background-weak);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="logo dark"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-slot="logo light"] {
|
||||
display: none;
|
||||
}
|
||||
[data-slot="logo dark"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="footer"] {
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 4rem;
|
||||
|
||||
@media (max-width: 65rem) {
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
}
|
||||
|
||||
[data-slot="cell"] {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
display: block;
|
||||
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: var(--color-background-weak);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="cell"] + [data-slot="cell"] {
|
||||
border-left: 1px solid var(--color-border-weak);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 25rem) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
[data-slot="cell"] {
|
||||
flex: 1 0 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
}
|
||||
|
||||
[data-slot="cell"]:nth-child(1) {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="container"] {
|
||||
max-width: 67.5rem;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--color-border-weak);
|
||||
border-top: none;
|
||||
|
||||
@media (max-width: 65rem) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="content"] {
|
||||
padding: 6rem 5rem;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 4rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="legal"] {
|
||||
color: var(--color-text-weak);
|
||||
text-align: center;
|
||||
padding: 2rem 5rem;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-weak);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Changelog Hero */
|
||||
[data-component="changelog-hero"] {
|
||||
margin-bottom: 4rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
/* Releases */
|
||||
[data-component="releases"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-component="release"] {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 3rem;
|
||||
padding: 2rem 0;
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-slot="version"] {
|
||||
a {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
time {
|
||||
color: var(--color-text-weak);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
[data-component="section"] {
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
li {
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "-";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
|
||||
[data-slot="author"] {
|
||||
color: var(--color-text-weak);
|
||||
font-size: 13px;
|
||||
margin-left: 4px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="contributors"] {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-weak);
|
||||
padding-top: 0.5rem;
|
||||
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
|
||||
&:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
147
packages/console/app/src/routes/changelog/index.tsx
Normal file
147
packages/console/app/src/routes/changelog/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import "./index.css"
|
||||
import { Title, Meta, Link } from "@solidjs/meta"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { Header } from "~/component/header"
|
||||
import { Footer } from "~/component/footer"
|
||||
import { Legal } from "~/component/legal"
|
||||
import { config } from "~/config"
|
||||
import { For, Show } from "solid-js"
|
||||
|
||||
type Release = {
|
||||
tag_name: string
|
||||
name: string
|
||||
body: string
|
||||
published_at: string
|
||||
html_url: string
|
||||
}
|
||||
|
||||
const getReleases = query(async () => {
|
||||
"use server"
|
||||
const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "OpenCode-Console",
|
||||
},
|
||||
cf: {
|
||||
cacheTtl: 60 * 5,
|
||||
cacheEverything: true,
|
||||
},
|
||||
} as any)
|
||||
if (!response.ok) return []
|
||||
return response.json() as Promise<Release[]>
|
||||
}, "releases.get")
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function parseMarkdown(body: string) {
|
||||
const lines = body.split("\n")
|
||||
const sections: { title: string; items: string[] }[] = []
|
||||
let current: { title: string; items: string[] } | null = null
|
||||
let skip = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
if (current) sections.push(current)
|
||||
const title = line.slice(3).trim()
|
||||
current = { title, items: [] }
|
||||
skip = false
|
||||
} else if (line.startsWith("**Thank you")) {
|
||||
skip = true
|
||||
} else if (line.startsWith("- ") && !skip) {
|
||||
current?.items.push(line.slice(2).trim())
|
||||
}
|
||||
}
|
||||
if (current) sections.push(current)
|
||||
|
||||
return { sections }
|
||||
}
|
||||
|
||||
function ReleaseItem(props: { item: string }) {
|
||||
const parts = () => {
|
||||
const match = props.item.match(/^(.+?)(\s*\(@([\w-]+)\))?$/)
|
||||
if (match) {
|
||||
return {
|
||||
text: match[1],
|
||||
username: match[3],
|
||||
}
|
||||
}
|
||||
return { text: props.item, username: undefined }
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<span>{parts().text}</span>
|
||||
<Show when={parts().username}>
|
||||
<a data-slot="author" href={`https://github.com/${parts().username}`} target="_blank" rel="noopener noreferrer">
|
||||
(@{parts().username})
|
||||
</a>
|
||||
</Show>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Changelog() {
|
||||
const releases = createAsync(() => getReleases())
|
||||
|
||||
return (
|
||||
<main data-page="changelog">
|
||||
<Title>OpenCode | Changelog</Title>
|
||||
<Link rel="canonical" href={`${config.baseUrl}/changelog`} />
|
||||
<Meta name="description" content="OpenCode release notes and changelog" />
|
||||
|
||||
<div data-component="container">
|
||||
<Header hideGetStarted />
|
||||
|
||||
<div data-component="content">
|
||||
<section data-component="changelog-hero">
|
||||
<h1>Changelog</h1>
|
||||
<p>New updates and improvements to OpenCode</p>
|
||||
</section>
|
||||
|
||||
<section data-component="releases">
|
||||
<For each={releases()}>
|
||||
{(release) => {
|
||||
const parsed = () => parseMarkdown(release.body || "")
|
||||
return (
|
||||
<article data-component="release">
|
||||
<header>
|
||||
<div data-slot="version">
|
||||
<a href={release.html_url} target="_blank" rel="noopener noreferrer">
|
||||
{release.tag_name}
|
||||
</a>
|
||||
</div>
|
||||
<time dateTime={release.published_at}>{formatDate(release.published_at)}</time>
|
||||
</header>
|
||||
<div data-slot="content">
|
||||
<For each={parsed().sections}>
|
||||
{(section) => (
|
||||
<div data-component="section">
|
||||
<h3>{section.title}</h3>
|
||||
<ul>
|
||||
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Legal />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export async function GET({ params: { platform } }: APIEvent) {
|
||||
const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, {
|
||||
cf: {
|
||||
// in case gh releases has rate limits
|
||||
cacheTtl: 60 * 60 * 24,
|
||||
cacheTtl: 60 * 5,
|
||||
cacheEverything: true,
|
||||
},
|
||||
} as any)
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -441,7 +441,8 @@ export default function Download() {
|
||||
</li>
|
||||
<li>
|
||||
<Faq question="Can I only use OpenCode in the terminal?">
|
||||
Not anymore! OpenCode is now available as an app for your desktop.
|
||||
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
|
||||
<a href="/docs/cli/#web">web</a>!
|
||||
</Faq>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -692,7 +692,8 @@ export default function Home() {
|
||||
</li>
|
||||
<li>
|
||||
<Faq question="Can I only use OpenCode in the terminal?">
|
||||
Not anymore! OpenCode is now available as an app for your desktop.
|
||||
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
|
||||
<a href="/docs/web">web</a>!
|
||||
</Faq>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `billing` ADD `time_subscription_booked` timestamp(3);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `billing` ADD `subscription_plan` enum('20','100','200');
|
||||
1228
packages/console/core/migrations/meta/0051_snapshot.json
Normal file
1228
packages/console/core/migrations/meta/0051_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1235
packages/console/core/migrations/meta/0052_snapshot.json
Normal file
1235
packages/console/core/migrations/meta/0052_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -358,6 +358,20 @@
|
||||
"when": 1767931290031,
|
||||
"tag": "0050_bumpy_mephistopheles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 51,
|
||||
"version": "5",
|
||||
"when": 1768341152722,
|
||||
"tag": "0051_jazzy_green_goblin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 52,
|
||||
"version": "5",
|
||||
"when": 1768343920467,
|
||||
"tag": "0052_aromatic_agent_zero",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.16",
|
||||
"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",
|
||||
|
||||
41
packages/console/core/script/black-cancel-waitlist.ts
Normal file
41
packages/console/core/script/black-cancel-waitlist.ts
Normal 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`)
|
||||
@@ -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")
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bigint, boolean, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
@@ -23,6 +23,8 @@ export const BillingTable = mysqlTable(
|
||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const),
|
||||
timeSubscriptionBooked: utc("time_subscription_booked"),
|
||||
},
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
|
||||
4
packages/console/core/sst-env.d.ts
vendored
4
packages/console/core/sst-env.d.ts
vendored
@@ -78,6 +78,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
4
packages/console/function/sst-env.d.ts
vendored
4
packages/console/function/sst-env.d.ts
vendored
@@ -78,6 +78,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
4
packages/console/resource/sst-env.d.ts
vendored
4
packages/console/resource/sst-env.d.ts
vendored
@@ -78,6 +78,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
1
packages/desktop/src-tauri/Cargo.lock
generated
1
packages/desktop/src-tauri/Cargo.lock
generated
@@ -2816,6 +2816,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -41,6 +41,15 @@ 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"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Security"
|
||||
] }
|
||||
|
||||
@@ -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",
|
||||
|
||||
145
packages/desktop/src-tauri/src/job_object.rs
Normal file
145
packages/desktop/src-tauri/src/job_object.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! Windows Job Object for reliable child process cleanup.
|
||||
//!
|
||||
//! This module provides a wrapper around Windows Job Objects with the
|
||||
//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag set. When the job object handle
|
||||
//! is closed (including when the parent process exits or crashes), Windows
|
||||
//! automatically terminates all processes assigned to the job.
|
||||
//!
|
||||
//! This is more reliable than manual cleanup because it works even if:
|
||||
//! - The parent process crashes
|
||||
//! - The parent is killed via Task Manager
|
||||
//! - The RunEvent::Exit handler fails to run
|
||||
|
||||
use std::io::{Error, Result};
|
||||
#[cfg(windows)]
|
||||
use std::sync::Mutex;
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject,
|
||||
};
|
||||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
|
||||
|
||||
/// A Windows Job Object configured to kill all assigned processes when closed.
|
||||
///
|
||||
/// When this struct is dropped or when the owning process exits (even abnormally),
|
||||
/// Windows will automatically terminate all processes that have been assigned to it.
|
||||
pub struct JobObject(HANDLE);
|
||||
|
||||
// SAFETY: HANDLE is just a pointer-sized value, and Windows job objects
|
||||
// can be safely accessed from multiple threads.
|
||||
unsafe impl Send for JobObject {}
|
||||
unsafe impl Sync for JobObject {}
|
||||
|
||||
impl JobObject {
|
||||
/// Creates a new anonymous job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set.
|
||||
///
|
||||
/// When the last handle to this job is closed (including on process exit),
|
||||
/// Windows will terminate all processes assigned to the job.
|
||||
pub fn new() -> Result<Self> {
|
||||
unsafe {
|
||||
// Create an anonymous job object
|
||||
let job = CreateJobObjectW(None, None).map_err(|e| Error::other(e.message()))?;
|
||||
|
||||
// Configure the job to kill all processes when the handle is closed
|
||||
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&info as *const _ as *const std::ffi::c_void,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
.map_err(|e| Error::other(e.message()))?;
|
||||
|
||||
Ok(Self(job))
|
||||
}
|
||||
}
|
||||
|
||||
/// Assigns a process to this job object by its process ID.
|
||||
///
|
||||
/// Once assigned, the process will be terminated when this job object is dropped
|
||||
/// or when the owning process exits.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pid` - The process ID of the process to assign
|
||||
pub fn assign_pid(&self, pid: u32) -> Result<()> {
|
||||
unsafe {
|
||||
// Open a handle to the process with the minimum required permissions
|
||||
// PROCESS_SET_QUOTA and PROCESS_TERMINATE are required by AssignProcessToJobObject
|
||||
let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)
|
||||
.map_err(|e| Error::other(e.message()))?;
|
||||
|
||||
// Assign the process to the job
|
||||
let result = AssignProcessToJobObject(self.0, process);
|
||||
|
||||
// Close our handle to the process - the job object maintains its own reference
|
||||
let _ = CloseHandle(process);
|
||||
|
||||
result.map_err(|e| Error::other(e.message()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for JobObject {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
// When this handle is closed and it's the last handle to the job,
|
||||
// Windows will terminate all processes in the job due to KILL_ON_JOB_CLOSE
|
||||
let _ = CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the Windows Job Object that ensures child processes are killed when the app exits.
|
||||
/// On Windows, when the job object handle is closed (including on crash), all assigned
|
||||
/// processes are automatically terminated by the OS.
|
||||
#[cfg(windows)]
|
||||
pub struct JobObjectState {
|
||||
job: Mutex<Option<JobObject>>,
|
||||
error: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl JobObjectState {
|
||||
pub fn new() -> Self {
|
||||
match JobObject::new() {
|
||||
Ok(job) => Self {
|
||||
job: Mutex::new(Some(job)),
|
||||
error: Mutex::new(None),
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create job object: {e}");
|
||||
Self {
|
||||
job: Mutex::new(None),
|
||||
error: Mutex::new(Some(format!("Failed to create job object: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assign_pid(&self, pid: u32) {
|
||||
if let Some(job) = self.job.lock().unwrap().as_ref() {
|
||||
if let Err(e) = job.assign_pid(pid) {
|
||||
eprintln!("Failed to assign process {pid} to job object: {e}");
|
||||
*self.error.lock().unwrap() =
|
||||
Some(format!("Failed to assign process to job object: {e}"));
|
||||
} else {
|
||||
println!("Assigned process {pid} to job object for automatic cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_job_object_creation() {
|
||||
let job = JobObject::new();
|
||||
assert!(job.is_ok(), "Failed to create job object: {:?}", job.err());
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
mod cli;
|
||||
#[cfg(windows)]
|
||||
mod job_object;
|
||||
mod window_customizer;
|
||||
|
||||
use cli::{install_cli, sync_cli};
|
||||
use futures::FutureExt;
|
||||
use futures::future;
|
||||
#[cfg(windows)]
|
||||
use job_object::*;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
net::TcpListener,
|
||||
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;
|
||||
@@ -194,7 +198,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild {
|
||||
}
|
||||
|
||||
async fn check_server_health(url: &str, password: Option<&str>) -> bool {
|
||||
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
||||
let health_url = format!("{}/global/health", url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build();
|
||||
@@ -219,6 +223,11 @@ 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")]
|
||||
let _ = std::process::Command::new("killall")
|
||||
.arg("opencode-cli")
|
||||
.output();
|
||||
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
// Focus existing window when another instance is launched
|
||||
@@ -228,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())
|
||||
@@ -251,34 +267,38 @@ pub fn run() {
|
||||
// Initialize log state
|
||||
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
||||
|
||||
#[cfg(windows)]
|
||||
app.manage(JobObjectState::new());
|
||||
|
||||
let primary_monitor = app.primary_monitor().ok().flatten();
|
||||
let size = primary_monitor
|
||||
.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));
|
||||
@@ -303,7 +323,14 @@ pub fn run() {
|
||||
|
||||
let res = match setup_server_connection(&app, custom_url).await {
|
||||
Ok((child, url)) => {
|
||||
#[cfg(windows)]
|
||||
if let Some(child) = &child {
|
||||
let job_state = app.state::<JobObjectState>();
|
||||
job_state.assign_pid(child.pid());
|
||||
}
|
||||
|
||||
app.state::<ServerState>().set_child(child);
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
@@ -427,7 +454,7 @@ async fn spawn_local_server(
|
||||
|
||||
let timestamp = Instant::now();
|
||||
loop {
|
||||
if timestamp.elapsed() > Duration::from_secs(7) {
|
||||
if timestamp.elapsed() > Duration::from_secs(30) {
|
||||
break Err(format!(
|
||||
"Failed to spawn OpenCode Server. Logs:\n{}",
|
||||
get_logs(app.clone()).await.unwrap()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,15 +301,29 @@ 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)
|
||||
window.__OPENCODE__ ??= {}
|
||||
window.__OPENCODE__.serverPassword = data().password ?? undefined
|
||||
|
||||
return <AppInterface defaultUrl={data().url} />
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
4
packages/enterprise/sst-env.d.ts
vendored
4
packages/enterprise/sst-env.d.ts
vendored
@@ -78,6 +78,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.16"
|
||||
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.16/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.16/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.16/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.16/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.16/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
4
packages/function/sst-env.d.ts
vendored
4
packages/function/sst-env.d.ts
vendored
@@ -78,6 +78,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.21",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -70,6 +70,7 @@
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.1.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -81,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",
|
||||
|
||||
@@ -106,8 +106,11 @@ async function main() {
|
||||
return
|
||||
}
|
||||
|
||||
const { binaryPath, binaryName } = findBinary()
|
||||
symlinkBinary(binaryPath, binaryName)
|
||||
// On non-Windows platforms, just verify the binary package exists
|
||||
// Don't replace the wrapper script - it handles binary execution
|
||||
const { binaryPath } = findBinary()
|
||||
console.log(`Platform binary verified at: ${binaryPath}`)
|
||||
console.log("Wrapper script will handle binary execution")
|
||||
} catch (error) {
|
||||
console.error("Failed to setup opencode binary:", error.message)
|
||||
process.exit(1)
|
||||
|
||||
@@ -13,6 +13,8 @@ import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@/global"
|
||||
import path from "path"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
@@ -53,6 +55,8 @@ export namespace Agent {
|
||||
[Truncate.GLOB]: "allow",
|
||||
},
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
@@ -71,6 +75,7 @@ export namespace Agent {
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
question: "allow",
|
||||
plan_enter: "allow",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
@@ -84,9 +89,14 @@ export namespace Agent {
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
question: "allow",
|
||||
plan_exit: "allow",
|
||||
external_directory: {
|
||||
[path.join(Global.Path.data, "plans", "*")]: "allow",
|
||||
},
|
||||
edit: {
|
||||
"*": "deny",
|
||||
".opencode/plan/*.md": "allow",
|
||||
[path.join(".opencode", "plans", "*.md")]: "allow",
|
||||
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
|
||||
},
|
||||
}),
|
||||
user,
|
||||
|
||||
@@ -338,9 +338,9 @@ export const AuthLoginCommand = cmd({
|
||||
prompts.log.info(
|
||||
"Amazon Bedrock authentication priority:\n" +
|
||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||
" 2. AWS credential chain (profile, access keys, IAM roles)\n\n" +
|
||||
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
|
||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
|
||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -394,6 +394,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@latest${envStr}
|
||||
|
||||
@@ -21,7 +21,7 @@ function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||
case "expired":
|
||||
return "⚠"
|
||||
case "not_authenticated":
|
||||
return "○"
|
||||
return "✗"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -26,67 +26,82 @@ export function createDialogProviderOptions() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const connected = createMemo(() => new Set(sync.data.provider_next.connected))
|
||||
const options = createMemo(() => {
|
||||
return pipe(
|
||||
sync.data.provider_next.all,
|
||||
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
|
||||
map((provider) => ({
|
||||
title: provider.name,
|
||||
value: provider.id,
|
||||
description: {
|
||||
opencode: "(Recommended)",
|
||||
anthropic: "(Claude Max or API key)",
|
||||
openai: "(ChatGPT Plus/Pro or API key)",
|
||||
}[provider.id],
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
|
||||
async onSelect() {
|
||||
const methods = sync.data.provider_auth[provider.id] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
},
|
||||
]
|
||||
let index: number | null = 0
|
||||
if (methods.length > 1) {
|
||||
index = await new Promise<number | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title="Select auth method"
|
||||
options={methods.map((x, index) => ({
|
||||
title: x.label,
|
||||
value: index,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
map((provider) => {
|
||||
const isConnected = connected().has(provider.id)
|
||||
return {
|
||||
title: provider.name,
|
||||
value: provider.id,
|
||||
description: {
|
||||
opencode: "(Recommended)",
|
||||
anthropic: "(Claude Max or API key)",
|
||||
openai: "(ChatGPT Plus/Pro or API key)",
|
||||
}[provider.id],
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
|
||||
footer: isConnected ? "Connected" : undefined,
|
||||
async onSelect() {
|
||||
const methods = sync.data.provider_auth[provider.id] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
},
|
||||
]
|
||||
let index: number | null = 0
|
||||
if (methods.length > 1) {
|
||||
index = await new Promise<number | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title="Select auth method"
|
||||
options={methods.map((x, index) => ({
|
||||
title: x.label,
|
||||
value: index,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
if (index == null) return
|
||||
const method = methods[index]
|
||||
if (method.type === "oauth") {
|
||||
const result = await sdk.client.provider.oauth.authorize({
|
||||
providerID: provider.id,
|
||||
method: index,
|
||||
})
|
||||
if (result.data?.method === "code") {
|
||||
dialog.replace(() => (
|
||||
<CodeMethod
|
||||
providerID={provider.id}
|
||||
title={method.label}
|
||||
index={index}
|
||||
authorization={result.data!}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
if (index == null) return
|
||||
const method = methods[index]
|
||||
if (method.type === "oauth") {
|
||||
const result = await sdk.client.provider.oauth.authorize({
|
||||
providerID: provider.id,
|
||||
method: index,
|
||||
})
|
||||
if (result.data?.method === "code") {
|
||||
dialog.replace(() => (
|
||||
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
))
|
||||
}
|
||||
if (result.data?.method === "auto") {
|
||||
dialog.replace(() => (
|
||||
<AutoMethod
|
||||
providerID={provider.id}
|
||||
title={method.label}
|
||||
index={index}
|
||||
authorization={result.data!}
|
||||
/>
|
||||
))
|
||||
}
|
||||
}
|
||||
if (result.data?.method === "auto") {
|
||||
dialog.replace(() => (
|
||||
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
if (method.type === "api") {
|
||||
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
|
||||
}
|
||||
}
|
||||
if (method.type === "api") {
|
||||
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
|
||||
}
|
||||
},
|
||||
})),
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
return options
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -159,6 +159,26 @@ export function Autocomplete(props: {
|
||||
})
|
||||
|
||||
props.setPrompt((draft) => {
|
||||
if (part.type === "file") {
|
||||
const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url)
|
||||
if (existingIndex !== -1) {
|
||||
const existing = draft.parts[existingIndex]
|
||||
if (
|
||||
part.source?.text &&
|
||||
existing &&
|
||||
"source" in existing &&
|
||||
existing.source &&
|
||||
"text" in existing.source &&
|
||||
existing.source.text
|
||||
) {
|
||||
existing.source.text.start = extmarkStart
|
||||
existing.source.text.end = extmarkEnd
|
||||
existing.source.text.value = virtualText
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
part.source.text.start = extmarkStart
|
||||
part.source.text.end = extmarkEnd
|
||||
|
||||
@@ -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"
|
||||
@@ -563,25 +564,27 @@ export function Prompt(props: PromptProps) {
|
||||
})),
|
||||
})
|
||||
} else {
|
||||
sdk.client.session.prompt({
|
||||
sessionID,
|
||||
...selectedModel,
|
||||
messageID,
|
||||
agent: local.agent.current().name,
|
||||
model: selectedModel,
|
||||
variant,
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: inputText,
|
||||
},
|
||||
...nonTextParts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
})
|
||||
sdk.client.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
...selectedModel,
|
||||
messageID,
|
||||
agent: local.agent.current().name,
|
||||
model: selectedModel,
|
||||
variant,
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: inputText,
|
||||
},
|
||||
...nonTextParts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
history.append({
|
||||
...store.prompt,
|
||||
@@ -1035,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
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ const TIPS = [
|
||||
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
|
||||
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
|
||||
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
|
||||
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use",
|
||||
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
|
||||
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
|
||||
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
|
||||
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,24 +25,27 @@ export function Footer() {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
// Track all timeouts to ensure proper cleanup
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function tick() {
|
||||
if (connected()) return
|
||||
if (!store.welcome) {
|
||||
setStore("welcome", true)
|
||||
timeout = setTimeout(() => tick(), 5000)
|
||||
timeouts.push(setTimeout(() => tick(), 5000))
|
||||
return
|
||||
}
|
||||
|
||||
if (store.welcome) {
|
||||
setStore("welcome", false)
|
||||
timeout = setTimeout(() => tick(), 10_000)
|
||||
timeouts.push(setTimeout(() => tick(), 10_000))
|
||||
return
|
||||
}
|
||||
}
|
||||
let timeout = setTimeout(() => tick(), 10_000)
|
||||
timeouts.push(setTimeout(() => tick(), 10_000))
|
||||
|
||||
onCleanup(() => {
|
||||
clearTimeout(timeout)
|
||||
timeouts.forEach(clearTimeout)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ import { Footer } from "./footer.tsx"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Global } from "@/global"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
import { QuestionPrompt } from "./question"
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
@@ -195,6 +196,23 @@ export function Session() {
|
||||
}
|
||||
})
|
||||
|
||||
let lastSwitch: string | undefined = undefined
|
||||
sdk.event.on("message.part.updated", (evt) => {
|
||||
const part = evt.properties.part
|
||||
if (part.type !== "tool") return
|
||||
if (part.sessionID !== route.sessionID) return
|
||||
if (part.state.status !== "completed") return
|
||||
if (part.id === lastSwitch) return
|
||||
|
||||
if (part.tool === "plan_exit") {
|
||||
local.agent.set("build")
|
||||
lastSwitch = part.id
|
||||
} else if (part.tool === "plan_enter") {
|
||||
local.agent.set("plan")
|
||||
lastSwitch = part.id
|
||||
}
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
let prompt: PromptRef
|
||||
const keybind = useKeybind()
|
||||
@@ -1525,6 +1543,7 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
|
||||
|
||||
function Bash(props: ToolProps<typeof BashTool>) {
|
||||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const lines = createMemo(() => output().split("\n"))
|
||||
@@ -1534,11 +1553,36 @@ function Bash(props: ToolProps<typeof BashTool>) {
|
||||
return [...lines().slice(0, 10), "…"].join("\n")
|
||||
})
|
||||
|
||||
const workdirDisplay = createMemo(() => {
|
||||
const workdir = props.input.workdir
|
||||
if (!workdir || workdir === ".") return undefined
|
||||
|
||||
const base = sync.data.path.directory
|
||||
if (!base) return undefined
|
||||
|
||||
const absolute = path.resolve(base, workdir)
|
||||
if (absolute === base) return undefined
|
||||
|
||||
const home = Global.Path.home
|
||||
if (!home) return absolute
|
||||
|
||||
const match = absolute === home || absolute.startsWith(home + path.sep)
|
||||
return match ? absolute.replace(home, "~") : absolute
|
||||
})
|
||||
|
||||
const title = createMemo(() => {
|
||||
const desc = props.input.description ?? "Shell"
|
||||
const wd = workdirDisplay()
|
||||
if (!wd) return `# ${desc}`
|
||||
if (desc.includes(wd)) return `# ${desc}`
|
||||
return `# ${desc} in ${wd}`
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.output !== undefined}>
|
||||
<BlockTool
|
||||
title={"# " + (props.input.description ?? "Shell")}
|
||||
title={title()}
|
||||
part={props.part}
|
||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
||||
>
|
||||
@@ -1850,10 +1894,10 @@ function Question(props: ToolProps<typeof QuestionTool>) {
|
||||
<Switch>
|
||||
<Match when={props.metadata.answers}>
|
||||
<BlockTool title="# Questions" part={props.part}>
|
||||
<box>
|
||||
<box gap={1}>
|
||||
<For each={props.input.questions ?? []}>
|
||||
{(q, i) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="column">
|
||||
<text fg={theme.textMuted}>{q.question}</text>
|
||||
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
|
||||
</box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
@@ -11,16 +11,28 @@ import { useSync } from "../../context/sync"
|
||||
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
|
||||
import path from "path"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Global } from "@/global"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
if (path.isAbsolute(input)) {
|
||||
return path.relative(process.cwd(), input) || "."
|
||||
|
||||
const cwd = process.cwd()
|
||||
const home = Global.Path.home
|
||||
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
||||
const relative = path.relative(cwd, absolute)
|
||||
|
||||
if (!relative) return "."
|
||||
if (!relative.startsWith("..")) return relative
|
||||
|
||||
// outside cwd - use ~ or absolute
|
||||
if (home && (absolute === home || absolute.startsWith(home + path.sep))) {
|
||||
return absolute.replace(home, "~")
|
||||
}
|
||||
return input
|
||||
return absolute
|
||||
}
|
||||
|
||||
function filetype(input?: string) {
|
||||
@@ -32,7 +44,9 @@ function filetype(input?: string) {
|
||||
}
|
||||
|
||||
function EditBody(props: { request: PermissionRequest }) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const themeState = useTheme()
|
||||
const theme = themeState.theme
|
||||
const syntax = themeState.syntax
|
||||
const sync = useSync()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
@@ -54,7 +68,7 @@ function EditBody(props: { request: PermissionRequest }) {
|
||||
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
|
||||
</box>
|
||||
<Show when={diff()}>
|
||||
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
|
||||
<scrollbox height="100%">
|
||||
<diff
|
||||
diff={diff()}
|
||||
view={view()}
|
||||
@@ -74,7 +88,7 @@ function EditBody(props: { request: PermissionRequest }) {
|
||||
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
@@ -172,86 +186,111 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
message: message || undefined,
|
||||
})
|
||||
}}
|
||||
onCancel={() => setStore("stage", "permission")}
|
||||
onCancel={() => {
|
||||
setStore("stage", "permission")
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={store.stage === "permission"}>
|
||||
<Prompt
|
||||
title="Permission required"
|
||||
body={
|
||||
<Switch>
|
||||
<Match when={props.request.permission === "edit"}>
|
||||
<EditBody request={props.request} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "read"}>
|
||||
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "glob"}>
|
||||
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "grep"}>
|
||||
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "list"}>
|
||||
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "bash"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={(input().description as string) ?? ""}
|
||||
description={("$ " + input().command) as string}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "task"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
|
||||
description={"◉ " + input().description}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "webfetch"}>
|
||||
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "websearch"}>
|
||||
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "codesearch"}>
|
||||
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "external_directory"}>
|
||||
<TextBody icon="←" title={`Access external directory ` + normalizePath(input().path as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "doom_loop"}>
|
||||
<TextBody icon="⟳" title="Continue after repeated failures" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
||||
escapeKey="reject"
|
||||
onSelect={(option) => {
|
||||
if (option === "always") {
|
||||
setStore("stage", "always")
|
||||
return
|
||||
}
|
||||
if (option === "reject") {
|
||||
if (session()?.parentID) {
|
||||
setStore("stage", "reject")
|
||||
return
|
||||
{(() => {
|
||||
const body = (
|
||||
<Prompt
|
||||
title="Permission required"
|
||||
body={
|
||||
<Switch>
|
||||
<Match when={props.request.permission === "edit"}>
|
||||
<EditBody request={props.request} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "read"}>
|
||||
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "glob"}>
|
||||
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "grep"}>
|
||||
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "list"}>
|
||||
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "bash"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={(input().description as string) ?? ""}
|
||||
description={("$ " + input().command) as string}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "task"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
|
||||
description={"◉ " + input().description}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "webfetch"}>
|
||||
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "websearch"}>
|
||||
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "codesearch"}>
|
||||
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "external_directory"}>
|
||||
{(() => {
|
||||
const meta = props.request.metadata ?? {}
|
||||
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
||||
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
|
||||
const pattern = props.request.patterns?.[0]
|
||||
const derived =
|
||||
typeof pattern === "string"
|
||||
? pattern.includes("*")
|
||||
? path.dirname(pattern)
|
||||
: pattern
|
||||
: undefined
|
||||
|
||||
const raw = parent ?? filepath ?? derived
|
||||
const dir = normalizePath(raw)
|
||||
|
||||
return <TextBody icon="←" title={`Access external directory ` + dir} />
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={props.request.permission === "doom_loop"}>
|
||||
<TextBody icon="⟳" title="Continue after repeated failures" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
sdk.client.permission.reply({
|
||||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
||||
escapeKey="reject"
|
||||
fullscreen
|
||||
onSelect={(option) => {
|
||||
if (option === "always") {
|
||||
setStore("stage", "always")
|
||||
return
|
||||
}
|
||||
if (option === "reject") {
|
||||
if (session()?.parentID) {
|
||||
setStore("stage", "reject")
|
||||
return
|
||||
}
|
||||
sdk.client.permission.reply({
|
||||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return body
|
||||
})()}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
@@ -327,14 +366,18 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
body: JSX.Element
|
||||
options: T
|
||||
escapeKey?: keyof T
|
||||
fullscreen?: boolean
|
||||
onSelect: (option: keyof T) => void
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keys = Object.keys(props.options) as (keyof T)[]
|
||||
const [store, setStore] = createStore({
|
||||
selected: keys[0],
|
||||
expanded: false,
|
||||
})
|
||||
const diffKey = Keybind.parse("ctrl+f")[0]
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "left" || evt.name == "h") {
|
||||
@@ -360,17 +403,36 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
evt.preventDefault()
|
||||
props.onSelect(props.escapeKey)
|
||||
}
|
||||
|
||||
if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
setStore("expanded", (v) => !v)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
|
||||
const renderer = useRenderer()
|
||||
|
||||
const content = () => (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.warning}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
{...(store.expanded
|
||||
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
|
||||
: {
|
||||
top: 0,
|
||||
maxHeight: 15,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
position: "relative",
|
||||
})}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<text fg={theme.warning}>{"△"}</text>
|
||||
<text fg={theme.text}>{props.title}</text>
|
||||
</box>
|
||||
@@ -403,6 +465,11 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
</For>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={props.fullscreen}>
|
||||
<text fg={theme.text}>
|
||||
{"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
|
||||
</text>
|
||||
@@ -413,4 +480,10 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={!store.expanded} fallback={<Portal>{content()}</Portal>}>
|
||||
{content()}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const other = createMemo(() => store.selected === options().length)
|
||||
const custom = createMemo(() => question()?.custom !== false)
|
||||
const other = createMemo(() => custom() && store.selected === options().length)
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const customPicked = createMemo(() => {
|
||||
@@ -121,6 +122,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const dialog = useDialog()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
// Skip processing if a dialog (e.g., command palette) is open
|
||||
if (dialog.stack.length > 0) return
|
||||
|
||||
// When editing "Other" textarea
|
||||
if (store.editing && !confirm()) {
|
||||
if (evt.name === "escape") {
|
||||
@@ -128,6 +132,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (keybind.match("input_clear", evt)) {
|
||||
evt.preventDefault()
|
||||
const text = textarea?.plainText ?? ""
|
||||
if (!text) {
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
textarea?.setText("")
|
||||
return
|
||||
}
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
const text = textarea?.plainText?.trim() ?? ""
|
||||
@@ -138,16 +152,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = ""
|
||||
setStore("custom", inputs)
|
||||
}
|
||||
|
||||
const answers = [...store.answers]
|
||||
if (prev) {
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
|
||||
setStore("answers", answers)
|
||||
}
|
||||
if (!prev) {
|
||||
answers[store.tab] = []
|
||||
}
|
||||
setStore("answers", answers)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
@@ -200,7 +209,17 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
}
|
||||
} else {
|
||||
const opts = options()
|
||||
const total = opts.length + 1 // options + "Other"
|
||||
const total = opts.length + (custom() ? 1 : 0)
|
||||
const max = Math.min(total, 9)
|
||||
const digit = Number(evt.name)
|
||||
|
||||
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
|
||||
evt.preventDefault()
|
||||
const index = digit - 1
|
||||
moveTo(index)
|
||||
selectOption()
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "up" || evt.name === "k") {
|
||||
evt.preventDefault()
|
||||
@@ -283,11 +302,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
|
||||
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
|
||||
{i() + 1}. {opt.label}
|
||||
{multi()
|
||||
? `${i() + 1}. [${picked() ? "✓" : " "}] ${opt.label}`
|
||||
: `${i() + 1}. ${opt.label}`}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{opt.description}</text>
|
||||
</box>
|
||||
@@ -295,35 +319,46 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
|
||||
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
|
||||
{options().length + 1}. Type your own answer
|
||||
</text>
|
||||
<Show when={custom()}>
|
||||
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
|
||||
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
|
||||
{multi()
|
||||
? `${options().length + 1}. [${customPicked() ? "✓" : " "}] Type your own answer`
|
||||
: `${options().length + 1}. Type your own answer`}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
|
||||
<Show when={store.editing}>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
queueMicrotask(() => {
|
||||
val.focus()
|
||||
val.gotoLineEnd()
|
||||
})
|
||||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
keyBindings={bindings()}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!store.editing && input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{input()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
focused
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
keyBindings={bindings()}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!store.editing && input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{input()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -337,9 +372,13 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const value = () => store.answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{q.header}:</text>
|
||||
<text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "}
|
||||
<span style={{ fg: answered() ? theme.text : theme.error }}>
|
||||
{answered() ? value() : "(not answered)"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -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} truncate={true} wrapMode="none">
|
||||
{item.file}
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={item.additions}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user