Compare commits
68
Commits
| 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 |
@@ -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
|
||||||
+29
@@ -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
|
# Reporting Security Issues
|
||||||
|
|
||||||
We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "2.0.0",
|
"@ai-sdk/anthropic": "2.0.0",
|
||||||
"@ai-sdk/openai": "2.0.2",
|
"@ai-sdk/openai": "2.0.2",
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -290,8 +290,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.72",
|
"@opentui/core": "0.1.73",
|
||||||
"@opentui/solid": "0.1.72",
|
"@opentui/solid": "0.1.73",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -374,7 +374,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.88.1",
|
"@hey-api/openapi-ts": "0.88.1",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -385,7 +385,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -398,7 +398,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -438,7 +438,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -505,7 +505,7 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "1.3.4",
|
"@types/bun": "1.3.5",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
"@types/node": "22.13.9",
|
"@types/node": "22.13.9",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
@@ -1215,21 +1215,21 @@
|
|||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@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=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
@@ -1773,7 +1773,7 @@
|
|||||||
|
|
||||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
"@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=="],
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
@@ -2075,7 +2075,7 @@
|
|||||||
|
|
||||||
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1768302833,
|
"lastModified": 1768395095,
|
||||||
"narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=",
|
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "61db79b0c6b838d9894923920b612048e1201926",
|
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ case $current_shell in
|
|||||||
config_files="$HOME/.config/fish/config.fish"
|
config_files="$HOME/.config/fish/config.fish"
|
||||||
;;
|
;;
|
||||||
zsh)
|
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)
|
bash)
|
||||||
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
|
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-1l4twtOi/7YYy1KFJME1XazAgTETAfbxB3EOv2qQeVs=",
|
"x86_64-linux": "sha256-XP1DXs1Fcfog99rjMryki9mMqn1g1H4ykHx7WDsnrnw=",
|
||||||
"aarch64-darwin": "sha256-jdZI3BA/v35er4xgWkI2rHo54D1TDNVhMX83b5BcIvk="
|
"aarch64-darwin": "sha256-fupiqvXkW3Cl44K+n1cDz81vOboMXIHPHTey6TewX70="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@types/bun": "1.3.4",
|
"@types/bun": "1.3.5",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
|
|||||||
@@ -13,12 +13,11 @@
|
|||||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||||
<meta property="og:image" content="/social-share.png" />
|
<meta property="og:image" content="/social-share.png" />
|
||||||
<meta property="twitter: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>
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<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>
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,267 +1,203 @@
|
|||||||
import { createMemo, createResource, Show } from "solid-js"
|
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 { useLayout } from "@/context/layout"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useServer } from "@/context/server"
|
// import { useServer } from "@/context/server"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
// import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
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 { iife } from "@opencode-ai/util/iife"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
|
||||||
import { Popover } from "@opencode-ai/ui/popover"
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
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() {
|
export function SessionHeader() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const server = useServer()
|
// const server = useServer()
|
||||||
const dialog = useDialog()
|
// const dialog = useDialog()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
||||||
|
const project = createMemo(() => {
|
||||||
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
|
const directory = projectDirectory()
|
||||||
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
|
if (!directory) return
|
||||||
const parentSession = createMemo(() => {
|
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
||||||
const current = currentSession()
|
|
||||||
if (!current?.parentID) return undefined
|
|
||||||
return sync.data.session.find((s) => s.id === current.parentID)
|
|
||||||
})
|
})
|
||||||
|
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 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 sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const view = createMemo(() => layout.view(sessionKey()))
|
const view = createMemo(() => layout.view(sessionKey()))
|
||||||
|
|
||||||
function navigateToProject(directory: string) {
|
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
||||||
navigate(`/${base64Encode(directory)}`)
|
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
||||||
}
|
|
||||||
|
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex">
|
<>
|
||||||
<button
|
<Show when={centerMount()}>
|
||||||
type="button"
|
{(mount) => (
|
||||||
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"
|
<Portal mount={mount()}>
|
||||||
onClick={layout.mobileSidebar.toggle}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Icon name="menu" size="small" />
|
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"
|
||||||
</button>
|
onClick={() => command.trigger("file.open")}
|
||||||
<div class="px-4 flex items-center justify-between gap-4 w-full">
|
>
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<Icon name="magnifying-glass" size="small" class="text-text-weak" />
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span>
|
||||||
<div class="hidden xl:flex items-center gap-2">
|
<Show when={hotkey()}>
|
||||||
<Select
|
{(keybind) => (
|
||||||
options={worktrees()}
|
<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">
|
||||||
current={sync.project?.worktree ?? projectDirectory()}
|
{keybind()}
|
||||||
label={(x) => getFilename(x)}
|
</span>
|
||||||
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
|
|
||||||
class="text-14-regular text-text-base"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
{(i) => (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon name="folder" size="small" />
|
|
||||||
<div class="text-text-strong">{getFilename(i)}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Select>
|
</Show>
|
||||||
<div class="text-text-weaker">/</div>
|
</button>
|
||||||
</div>
|
</Portal>
|
||||||
<Show
|
)}
|
||||||
when={parentSession()}
|
</Show>
|
||||||
fallback={
|
<Show when={rightMount()}>
|
||||||
<>
|
{(mount) => (
|
||||||
<Select
|
<Portal mount={mount()}>
|
||||||
options={sessions()}
|
<div class="flex items-center gap-3">
|
||||||
current={currentSession()}
|
{/* <div class="hidden md:flex items-center gap-1"> */}
|
||||||
placeholder="New session"
|
{/* <Button */}
|
||||||
label={(x) => x.title}
|
{/* size="small" */}
|
||||||
value={(x) => x.id}
|
{/* variant="ghost" */}
|
||||||
onSelect={navigateToSession}
|
{/* onClick={() => { */}
|
||||||
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
{/* dialog.show(() => <DialogSelectServer />) */}
|
||||||
variant="ghost"
|
{/* }} */}
|
||||||
/>
|
{/* > */}
|
||||||
</>
|
{/* <div */}
|
||||||
}
|
{/* classList={{ */}
|
||||||
>
|
{/* "size-1.5 rounded-full": true, */}
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
{/* "bg-icon-success-base": server.healthy() === true, */}
|
||||||
<Select
|
{/* "bg-icon-critical-base": server.healthy() === false, */}
|
||||||
options={sessions()}
|
{/* "bg-border-weak-base": server.healthy() === undefined, */}
|
||||||
current={parentSession()}
|
{/* }} */}
|
||||||
placeholder="Back to parent session"
|
{/* /> */}
|
||||||
label={(x) => x.title}
|
{/* <Icon name="server" size="small" class="text-icon-weak" /> */}
|
||||||
value={(x) => x.id}
|
{/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */}
|
||||||
onSelect={(session) => {
|
{/* </Button> */}
|
||||||
// Only navigate if selecting a different session than current parent
|
{/* <SessionLspIndicator /> */}
|
||||||
const currentParent = parentSession()
|
{/* <SessionMcpIndicator /> */}
|
||||||
if (session && currentParent && session.id !== currentParent.id) {
|
{/* </div> */}
|
||||||
navigateToSession(session)
|
<div class="flex items-center gap-1">
|
||||||
}
|
<Show when={currentSession()?.summary?.files}>
|
||||||
}}
|
<TooltipKeybind
|
||||||
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
class="hidden md:block shrink-0"
|
||||||
variant="ghost"
|
title="Toggle review"
|
||||||
/>
|
keybind={command.keybind("review.toggle")}
|
||||||
<div class="text-text-weaker">/</div>
|
>
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<Button
|
||||||
<Tooltip value="Back to parent session">
|
variant="ghost"
|
||||||
<button
|
class="group/review-toggle size-6 p-0"
|
||||||
type="button"
|
onClick={() => view().reviewPanel.toggle()}
|
||||||
class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0"
|
|
||||||
onClick={() => navigateToSession(parentSession())}
|
|
||||||
>
|
>
|
||||||
<Icon name="arrow-left" size="small" class="text-icon-base" />
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
</button>
|
<Icon
|
||||||
</Tooltip>
|
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
|
||||||
</div>
|
size="small"
|
||||||
</div>
|
class="group-hover/review-toggle:hidden"
|
||||||
</Show>
|
/>
|
||||||
</div>
|
<Icon
|
||||||
<Show when={currentSession() && !parentSession()}>
|
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
|
||||||
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
|
size="small"
|
||||||
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
|
class="hidden group-hover/review-toggle:inline-block"
|
||||||
</TooltipKeybind>
|
/>
|
||||||
</Show>
|
<Icon
|
||||||
</div>
|
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
|
||||||
<div class="flex items-center gap-3">
|
size="small"
|
||||||
<div class="hidden md:flex items-center gap-1">
|
class="hidden group-active/review-toggle:inline-block"
|
||||||
<Button
|
/>
|
||||||
size="small"
|
</div>
|
||||||
variant="ghost"
|
</Button>
|
||||||
onClick={() => {
|
</TooltipKeybind>
|
||||||
dialog.show(() => <DialogSelectServer />)
|
</Show>
|
||||||
}}
|
<TooltipKeybind
|
||||||
>
|
class="hidden md:block shrink-0"
|
||||||
<div
|
title="Toggle terminal"
|
||||||
classList={{
|
keybind={command.keybind("terminal.toggle")}
|
||||||
"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">
|
<Button
|
||||||
<Icon
|
variant="ghost"
|
||||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
|
class="group/terminal-toggle size-6 p-0"
|
||||||
size="small"
|
onClick={() => view().terminal.toggle()}
|
||||||
class="group-hover/review-toggle:hidden"
|
>
|
||||||
/>
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
<Icon
|
<Icon
|
||||||
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
|
size="small"
|
||||||
size="small"
|
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
||||||
class="hidden group-hover/review-toggle:inline-block"
|
class="group-hover/terminal-toggle:hidden"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
|
size="small"
|
||||||
size="small"
|
name="layout-bottom-partial"
|
||||||
class="hidden group-active/review-toggle:inline-block"
|
class="hidden group-hover/terminal-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Icon
|
||||||
</Button>
|
size="small"
|
||||||
</TooltipKeybind>
|
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
||||||
</Show>
|
class="hidden group-active/terminal-toggle:inline-block"
|
||||||
<TooltipKeybind
|
/>
|
||||||
class="hidden md:block shrink-0"
|
</div>
|
||||||
title="Toggle terminal"
|
</Button>
|
||||||
keybind={command.keybind("terminal.toggle")}
|
</TooltipKeybind>
|
||||||
>
|
</div>
|
||||||
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().terminal.toggle()}>
|
<Show when={shareEnabled() && currentSession()}>
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<Popover
|
||||||
<Icon
|
title="Share session"
|
||||||
size="small"
|
trigger={
|
||||||
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
<Tooltip class="shrink-0" value="Share session">
|
||||||
class="group-hover/terminal-toggle:hidden"
|
<IconButton icon="share" variant="ghost" class="" />
|
||||||
/>
|
</Tooltip>
|
||||||
<Icon
|
}
|
||||||
size="small"
|
>
|
||||||
name="layout-bottom-partial"
|
{iife(() => {
|
||||||
class="hidden group-hover/terminal-toggle:inline-block"
|
const [url] = createResource(
|
||||||
/>
|
() => currentSession(),
|
||||||
<Icon
|
async (session) => {
|
||||||
size="small"
|
if (!session) return
|
||||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
let shareURL = session.share?.url
|
||||||
class="hidden group-active/terminal-toggle:inline-block"
|
if (!shareURL) {
|
||||||
/>
|
shareURL = await globalSDK.client.session
|
||||||
</div>
|
.share({ sessionID: session.id, directory: projectDirectory() })
|
||||||
</Button>
|
.then((r) => r.data?.share?.url)
|
||||||
</TooltipKeybind>
|
.catch((e) => {
|
||||||
</div>
|
console.error("Failed to share session", e)
|
||||||
<Show when={shareEnabled() && currentSession()}>
|
return undefined
|
||||||
<Popover
|
})
|
||||||
title="Share session"
|
}
|
||||||
trigger={
|
return shareURL
|
||||||
<Tooltip class="shrink-0" value="Share session">
|
},
|
||||||
<IconButton icon="share" variant="ghost" class="" />
|
{ initialValue: "" },
|
||||||
</Tooltip>
|
)
|
||||||
}
|
return (
|
||||||
>
|
<Show when={url.latest}>
|
||||||
{iife(() => {
|
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
|
||||||
const [url] = createResource(
|
</Show>
|
||||||
() => currentSession(),
|
)
|
||||||
async (session) => {
|
})}
|
||||||
if (!session) return
|
</Popover>
|
||||||
let shareURL = session.share?.url
|
</Show>
|
||||||
if (!shareURL) {
|
</div>
|
||||||
shareURL = await globalSDK.client.session
|
</Portal>
|
||||||
.share({ sessionID: session.id, directory: projectDirectory() })
|
)}
|
||||||
.then((r) => r.data?.share?.url)
|
</Show>
|
||||||
.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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -124,12 +124,19 @@ function createGlobalSync() {
|
|||||||
return globalSDK.client.session
|
return globalSDK.client.session
|
||||||
.list({ directory, roots: true })
|
.list({ directory, roots: true })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
.filter((s) => !s.time?.archived)
|
.filter((s) => !s.time?.archived)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.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
|
// Include up to the limit, plus any updated in the last 4 hours
|
||||||
const sessions = nonArchived.filter((s, i) => {
|
const sessions = nonArchived.filter((s, i) => {
|
||||||
if (i < limit) return true
|
if (i < limit) return true
|
||||||
|
|||||||
@@ -47,12 +47,34 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
const globalSdk = useGlobalSDK()
|
const globalSdk = useGlobalSDK()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const server = useServer()
|
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(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("layout", ["layout.v6"]),
|
{ ...target, migrate },
|
||||||
createStore({
|
createStore({
|
||||||
sidebar: {
|
sidebar: {
|
||||||
opened: false,
|
opened: false,
|
||||||
width: 280,
|
width: 280,
|
||||||
|
workspaces: {} as Record<string, boolean>,
|
||||||
|
workspacesDefault: false,
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
height: 280,
|
height: 280,
|
||||||
@@ -304,6 +326,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
resize(width: number) {
|
resize(width: number) {
|
||||||
setStore("sidebar", "width", width)
|
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: {
|
terminal: {
|
||||||
height: createMemo(() => store.terminal.height),
|
height: createMemo(() => store.terminal.height),
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export type Platform = {
|
|||||||
/** Platform discriminator */
|
/** Platform discriminator */
|
||||||
platform: "web" | "desktop"
|
platform: "web" | "desktop"
|
||||||
|
|
||||||
|
/** Desktop OS (Tauri only) */
|
||||||
|
os?: "macos" | "windows" | "linux"
|
||||||
|
|
||||||
/** App version */
|
/** App version */
|
||||||
version?: string
|
version?: string
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const [store, setStore] = globalSync.child(sdk.directory)
|
const [store, setStore] = globalSync.child(sdk.directory)
|
||||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||||
const chunk = 200
|
const chunk = 400
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
|
|||||||
@@ -5,3 +5,7 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*[data-tauri-drag-region] {
|
||||||
|
app-region: drag;
|
||||||
|
}
|
||||||
|
|||||||
+682
-495
File diff suppressed because it is too large
Load Diff
@@ -885,6 +885,19 @@ export default function Page() {
|
|||||||
window.history.replaceState(null, "", `#${anchor(id)}`)
|
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") => {
|
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||||
setActiveMessage(message)
|
setActiveMessage(message)
|
||||||
|
|
||||||
@@ -896,7 +909,7 @@ export default function Page() {
|
|||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = document.getElementById(anchor(message.id))
|
const el = document.getElementById(anchor(message.id))
|
||||||
if (el) el.scrollIntoView({ behavior, block: "start" })
|
if (el) scrollToElement(el, behavior)
|
||||||
})
|
})
|
||||||
|
|
||||||
updateHash(message.id)
|
updateHash(message.id)
|
||||||
@@ -904,7 +917,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const el = document.getElementById(anchor(message.id))
|
const el = document.getElementById(anchor(message.id))
|
||||||
if (el) el.scrollIntoView({ behavior, block: "start" })
|
if (el) scrollToElement(el, behavior)
|
||||||
updateHash(message.id)
|
updateHash(message.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,7 +969,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const hashTarget = document.getElementById(hash)
|
const hashTarget = document.getElementById(hash)
|
||||||
if (hashTarget) {
|
if (hashTarget) {
|
||||||
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
|
scrollToElement(hashTarget, "auto")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../ui/src/assets/images/social-share-black.png
|
||||||
@@ -24,6 +24,9 @@ export function Footer() {
|
|||||||
<div data-slot="cell">
|
<div data-slot="cell">
|
||||||
<a href="/docs">Docs</a>
|
<a href="/docs">Docs</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div data-slot="cell">
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
</div>
|
||||||
<div data-slot="cell">
|
<div data-slot="cell">
|
||||||
<a href="/discord">Discord</a>
|
<a href="/discord">Discord</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 288px;
|
height: 288px;
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="header"] {
|
[data-component="header"] {
|
||||||
@@ -24,9 +24,6 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 40px;
|
padding-top: 40px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
/* [data-component="header-logo"] { */
|
|
||||||
/* } */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="content"] {
|
[data-component="content"] {
|
||||||
@@ -58,20 +55,20 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: rgba(255, 255, 255, 0.59);
|
color: rgba(255, 255, 255, 0.59);
|
||||||
font-size: 15px;
|
font-size: 18px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 160%;
|
line-height: 160%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
font-size: 18px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,24 +82,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
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%;
|
width: 100%;
|
||||||
max-width: 540px;
|
max-width: 590px;
|
||||||
height: auto;
|
height: auto;
|
||||||
filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.1));
|
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"] {
|
[data-slot="cta"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 32px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: -40px;
|
margin-top: -32px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
margin-top: -20px;
|
margin-top: -16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="heading"] {
|
[data-slot="heading"] {
|
||||||
@@ -111,12 +123,13 @@
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 160%; /* 28.8px */
|
line-height: 160%;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="subheading"] {
|
[data-slot="subheading"] {
|
||||||
color: rgba(255, 255, 255, 0.59);
|
color: rgba(255, 255, 255, 0.59);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -129,6 +142,7 @@
|
|||||||
line-height: 160%;
|
line-height: 160%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="button"] {
|
[data-slot="button"] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -140,7 +154,7 @@
|
|||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #000;
|
color: #000;
|
||||||
font-family: "JetBrains Mono Nerd Font";
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -154,14 +168,16 @@
|
|||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="back-soon"] {
|
[data-slot="back-soon"] {
|
||||||
color: rgba(255, 255, 255, 0.59);
|
color: rgba(255, 255, 255, 0.59);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 160%; /* 20.8px */
|
line-height: 160%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="follow-us"] {
|
[data-slot="follow-us"] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -172,7 +188,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||||
color: rgba(255, 255, 255, 0.59);
|
color: rgba(255, 255, 255, 0.59);
|
||||||
font-family: "JetBrains Mono Nerd Font";
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -185,84 +201,98 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 540px;
|
max-width: 680px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
@media (min-width: 768px) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="pricing-card"] {
|
[data-slot="pricing-card"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
align-items: flex-start;
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||||
border-radius: 4px;
|
border-radius: 5px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: border-color 0.15s ease;
|
background: #000;
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 200ms ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover:not([data-selected="true"]) {
|
||||||
border-color: rgba(255, 255, 255, 0.35);
|
border-color: rgba(255, 255, 255, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="icon"] {
|
[data-slot="card-trigger"] {
|
||||||
color: rgba(255, 255, 255, 0.59);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="price"] {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
align-items: baseline;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
width: 100%;
|
||||||
}
|
padding: 24px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
transition: padding 200ms ease;
|
||||||
|
|
||||||
[data-slot="amount"] {
|
&:disabled {
|
||||||
color: rgba(255, 255, 255, 0.92);
|
cursor: default;
|
||||||
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"] {
|
&[data-selected="true"] {
|
||||||
display: flex;
|
[data-slot="amount"] {
|
||||||
flex-direction: column;
|
font-size: 22px;
|
||||||
gap: 32px;
|
}
|
||||||
width: fit-content;
|
|
||||||
max-width: calc(100% - 40px);
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="selected-card"] {
|
[data-slot="terms"] {
|
||||||
display: flex;
|
animation: reveal 500ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
|
||||||
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"] {
|
[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);
|
color: rgba(255, 255, 255, 0.59);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="price"] {
|
[data-slot="price"] {
|
||||||
@@ -270,22 +300,31 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
line-height: 24px;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="amount"] {
|
[data-slot="amount"] {
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="period"] {
|
[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);
|
color: rgba(255, 255, 255, 0.59);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="multiplier"] {
|
[data-slot="multiplier"] {
|
||||||
color: rgba(255, 255, 255, 0.39);
|
color: rgba(255, 255, 255, 0.39);
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "·";
|
content: "·";
|
||||||
@@ -295,27 +334,32 @@
|
|||||||
|
|
||||||
[data-slot="terms"] {
|
[data-slot="terms"] {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0 24px 24px 24px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
text-align: left;
|
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%;
|
||||||
|
}
|
||||||
|
|
||||||
li {
|
[data-slot="terms"] li {
|
||||||
color: rgba(255, 255, 255, 0.59);
|
color: rgba(255, 255, 255, 0.59);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.2;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "▪";
|
content: "▪";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
color: rgba(255, 255, 255, 0.39);
|
color: rgba(255, 255, 255, 0.39);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,41 +367,48 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
padding: 0 24px 24px 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
button,
|
[data-slot="actions"] button,
|
||||||
a {
|
[data-slot="actions"] a {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
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="cancel"] {
|
[data-slot="continue"] {
|
||||||
background: transparent;
|
background: rgb(255, 255, 255);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
color: rgb(0, 0, 0);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: rgba(255, 255, 255, 0.35);
|
background: rgb(255, 255, 255, 0.9);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,8 +419,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 160%; /* 20.8px */
|
line-height: 160%;
|
||||||
font-style: italic;
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: rgba(255, 255, 255, 0.39);
|
color: rgba(255, 255, 255, 0.39);
|
||||||
@@ -441,7 +491,7 @@
|
|||||||
|
|
||||||
[data-slot="multiplier"] {
|
[data-slot="multiplier"] {
|
||||||
color: rgba(255, 255, 255, 0.39);
|
color: rgba(255, 255, 255, 0.39);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "·";
|
content: "·";
|
||||||
@@ -689,7 +739,7 @@
|
|||||||
span,
|
span,
|
||||||
a {
|
a {
|
||||||
color: rgba(255, 255, 255, 0.39);
|
color: rgba(255, 255, 255, 0.39);
|
||||||
font-family: "JetBrains Mono Nerd Font";
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -699,7 +749,7 @@
|
|||||||
|
|
||||||
[data-slot="github-stars"] {
|
[data-slot="github-stars"] {
|
||||||
color: rgba(255, 255, 255, 0.25);
|
color: rgba(255, 255, 255, 0.25);
|
||||||
font-family: "JetBrains Mono Nerd Font";
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -714,9 +764,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="anomaly-alt"] {
|
[data-slot="anomaly-alt"] {
|
||||||
color: rgba(255, 255, 255, 0.39);
|
color: rgba(255, 255, 255, 0.39);
|
||||||
font-family: "JetBrains Mono Nerd Font";
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -726,7 +777,7 @@
|
|||||||
|
|
||||||
a {
|
a {
|
||||||
color: rgba(255, 255, 255, 0.39);
|
color: rgba(255, 255, 255, 0.39);
|
||||||
font-family: "JetBrains Mono Nerd Font";
|
font-family: "JetBrains Mono Nerd Font", monospace;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -740,3 +791,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,8 +2,8 @@ import { Match, Switch } from "solid-js"
|
|||||||
|
|
||||||
export const plans = [
|
export const plans = [
|
||||||
{ id: "20", multiplier: null },
|
{ id: "20", multiplier: null },
|
||||||
{ id: "100", multiplier: "6x more usage than Black 20" },
|
{ id: "100", multiplier: "5x more usage than Black 20" },
|
||||||
{ id: "200", multiplier: "21x more usage than Black 20" },
|
{ id: "200", multiplier: "20x more usage than Black 20" },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type PlanID = (typeof plans)[number]["id"]
|
export type PlanID = (typeof plans)[number]["id"]
|
||||||
@@ -14,28 +14,47 @@ export function PlanIcon(props: { plan: string }) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.plan === "20"}>
|
<Match when={props.plan === "20"}>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="1.5" />
|
<title>Black 20 plan</title>
|
||||||
|
<rect x="0.5" y="0.5" width="23" height="23" stroke="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.plan === "100"}>
|
<Match when={props.plan === "100"}>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
<title>Black 100 plan</title>
|
||||||
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
<rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" />
|
||||||
<rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
<rect x="0.5" y="14.5" width="9" height="9" stroke="currentColor" />
|
||||||
<rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
<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>
|
</svg>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.plan === "200"}>
|
<Match when={props.plan === "200"}>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="2" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
<title>Black 200 plan</title>
|
||||||
<rect x="10" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
<rect x="0.5" y="0.5" width="3" height="3" stroke="currentColor" />
|
||||||
<rect x="18" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
<rect x="0.5" y="5.5" width="3" height="3" stroke="currentColor" />
|
||||||
<rect x="2" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
<rect x="0.5" y="10.5" width="3" height="3" stroke="currentColor" />
|
||||||
<rect x="10" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
<rect x="0.5" y="15.5" width="3" height="3" stroke="currentColor" />
|
||||||
<rect x="18" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
<rect x="0.5" y="20.5" width="3" height="3" stroke="currentColor" />
|
||||||
<rect x="2" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
<rect x="5.5" y="0.5" width="3" height="3" stroke="currentColor" />
|
||||||
<rect x="10" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
<rect x="5.5" y="5.5" width="3" height="3" stroke="currentColor" />
|
||||||
<rect x="18" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
<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>
|
</svg>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -1,79 +1,148 @@
|
|||||||
import { A, useSearchParams } from "@solidjs/router"
|
import { A, useSearchParams } from "@solidjs/router"
|
||||||
import { Title } from "@solidjs/meta"
|
import { Title } from "@solidjs/meta"
|
||||||
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
import { createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||||
import { PlanIcon, plans } from "./common"
|
import { PlanIcon, plans } from "./common"
|
||||||
|
|
||||||
export default function Black() {
|
export default function Black() {
|
||||||
const [params] = useSearchParams()
|
const [params] = useSearchParams()
|
||||||
const [selected, setSelected] = createSignal<string | null>((params.plan as string) || null)
|
const [selected, setSelected] = createSignal<string | null>((params.plan as string) || null)
|
||||||
const selectedPlan = createMemo(() => plans.find((p) => p.id === selected()))
|
const [mounted, setMounted] = createSignal(false)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
requestAnimationFrame(() => setMounted(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
const transition = (action: () => void) => {
|
||||||
|
if (mounted() && "startViewTransition" in document) {
|
||||||
|
;(document as any).startViewTransition(action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = (planId: string) => {
|
||||||
|
if (selected() === planId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transition(() => setSelected(planId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
transition(() => setSelected(null))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>opencode</Title>
|
<Title>opencode</Title>
|
||||||
<section data-slot="cta">
|
<section data-slot="cta">
|
||||||
<Switch>
|
<div data-slot="pricing">
|
||||||
<Match when={!selected()}>
|
<For each={plans}>
|
||||||
<div data-slot="pricing">
|
{(plan) => {
|
||||||
<For each={plans}>
|
const isSelected = createMemo(() => selected() === plan.id)
|
||||||
{(plan) => (
|
const isCollapsed = createMemo(() => selected() !== null && selected() !== plan.id)
|
||||||
<button type="button" onClick={() => setSelected(plan.id)} data-slot="pricing-card">
|
|
||||||
<div data-slot="icon">
|
return (
|
||||||
<PlanIcon plan={plan.id} />
|
<article
|
||||||
|
data-slot="pricing-card"
|
||||||
|
data-plan-id={plan.id}
|
||||||
|
data-selected={isSelected() ? "true" : "false"}
|
||||||
|
data-collapsed={isCollapsed() ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="card-trigger"
|
||||||
|
onClick={() => select(plan.id)}
|
||||||
|
disabled={isSelected()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-slot="plan-header"
|
||||||
|
style={{
|
||||||
|
"view-transition-name": `plan-header-${plan.id}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div data-slot="plan-icon">
|
||||||
|
<PlanIcon plan={plan.id} />
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
data-slot="price"
|
||||||
|
style={{
|
||||||
|
"view-transition-name": `price-${plan.id}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="amount"
|
||||||
|
style={{
|
||||||
|
"view-transition-name": `amount-${plan.id}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${plan.id}
|
||||||
|
</span>
|
||||||
|
<Show when={!isSelected()}>
|
||||||
|
<span
|
||||||
|
data-slot="period"
|
||||||
|
style={{
|
||||||
|
"view-transition-name": `period-${plan.id}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
per month
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={isSelected()}>
|
||||||
|
<span
|
||||||
|
data-slot="billing"
|
||||||
|
style={{
|
||||||
|
"view-transition-name": `billing-${plan.id}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
per person billed monthly
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
{plan.multiplier && (
|
||||||
|
<span
|
||||||
|
data-slot="multiplier"
|
||||||
|
style={{
|
||||||
|
"view-transition-name": `multiplier-${plan.id}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plan.multiplier}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p data-slot="price">
|
|
||||||
<span data-slot="amount">${plan.id}</span> <span data-slot="period">per month</span>
|
|
||||||
<Show when={plan.multiplier}>
|
|
||||||
<span data-slot="multiplier">{plan.multiplier}</span>
|
|
||||||
</Show>
|
|
||||||
</p>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</For>
|
<Show when={isSelected()}>
|
||||||
</div>
|
<div data-slot="content">
|
||||||
<p data-slot="fine-print">
|
<ul data-slot="terms">
|
||||||
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
|
<li>You will be added to the waitlist and activated in batches</li>
|
||||||
</p>
|
<li>Card won't be charged until subscription is active</li>
|
||||||
</Match>
|
<li>Not unlimited - limits apply and may be adjusted dynamically</li>
|
||||||
<Match when={selectedPlan()}>
|
<li>Heavily automated usage will hit limits quickly</li>
|
||||||
{(plan) => (
|
<li>Plans may be discontinued</li>
|
||||||
<div data-slot="selected-plan">
|
<li>Can cancel subscription at anytime</li>
|
||||||
<div data-slot="selected-card">
|
<li>Cannot issue refunds for consumed subscriptions</li>
|
||||||
<div data-slot="icon">
|
</ul>
|
||||||
<PlanIcon plan={plan().id} />
|
<div data-slot="actions">
|
||||||
</div>
|
<button type="button" onClick={cancel} data-slot="cancel">
|
||||||
<p data-slot="price">
|
Cancel
|
||||||
<span data-slot="amount">${plan().id}</span>{" "}
|
</button>
|
||||||
<span data-slot="period">per person billed monthly</span>
|
<a href={`/black/subscribe/${plan.id}`} data-slot="continue">
|
||||||
<Show when={plan().multiplier}>
|
Continue
|
||||||
<span data-slot="multiplier">{plan().multiplier}</span>
|
</a>
|
||||||
</Show>
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<ul data-slot="terms">
|
</Show>
|
||||||
<li>Your subscription will not start immediately</li>
|
</article>
|
||||||
<li>You will be added to the waitlist and activated soon</li>
|
)
|
||||||
<li>Your card will be only charged when your subscription is activated</li>
|
}}
|
||||||
<li>Usage limits apply, heavily automated use may reach limits sooner</li>
|
</For>
|
||||||
<li>Subscriptions for individuals, contact Enterprise for teams</li>
|
</div>
|
||||||
<li>Limits may be adjusted and plans may be discontinued in the future</li>
|
<p data-slot="fine-print">
|
||||||
<li>Cancel your subscription at anytime</li>
|
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
|
||||||
</ul>
|
</p>
|
||||||
<div data-slot="actions">
|
|
||||||
<button type="button" onClick={() => setSelected(null)} data-slot="cancel">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
|
|
||||||
Continue
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p data-slot="fine-print">
|
|
||||||
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
|
|||||||
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
|
const 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 stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
|
||||||
|
|
||||||
const getWorkspaces = query(async () => {
|
const getWorkspaces = query(async (plan: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
const actor = await getActor()
|
const actor = await getActor()
|
||||||
if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe")
|
if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe/" + plan)
|
||||||
return withActor(async () => {
|
return withActor(async () => {
|
||||||
return Database.use((tx) =>
|
return Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
@@ -258,15 +258,16 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BlackSubscribe() {
|
export default function BlackSubscribe() {
|
||||||
const workspaces = createAsync(() => getWorkspaces())
|
const params = useParams()
|
||||||
|
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
|
||||||
|
const plan = planData.id
|
||||||
|
|
||||||
|
const workspaces = createAsync(() => getWorkspaces(plan))
|
||||||
const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined)
|
const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined)
|
||||||
const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined)
|
const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined)
|
||||||
const [failure, setFailure] = createSignal<string | undefined>(undefined)
|
const [failure, setFailure] = createSignal<string | undefined>(undefined)
|
||||||
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
|
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
|
||||||
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
|
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
|
||||||
const params = useParams()
|
|
||||||
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
|
|
||||||
const plan = planData.id
|
|
||||||
|
|
||||||
// Resolve stripe promise once
|
// Resolve stripe promise once
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
padding-bottom: 5rem;
|
padding-bottom: 5rem;
|
||||||
overflow-x: hidden;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
--color-background: hsl(0, 9%, 7%);
|
--color-background: hsl(0, 9%, 7%);
|
||||||
|
|||||||
@@ -693,7 +693,7 @@ export default function Home() {
|
|||||||
<li>
|
<li>
|
||||||
<Faq question="Can I only use OpenCode in the terminal?">
|
<Faq question="Can I only use OpenCode in the terminal?">
|
||||||
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
|
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
|
||||||
<a href="/docs/cli/#web">web</a>!
|
<a href="/docs/web">web</a>!
|
||||||
</Faq>
|
</Faq>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -5,4 +5,58 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
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 { createStore } from "solid-js/store"
|
||||||
|
import { Show } from "solid-js"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.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 { withActor } from "~/context/auth.withActor"
|
||||||
import { queryBillingInfo } from "../../common"
|
import { queryBillingInfo } from "../../common"
|
||||||
import styles from "./black-section.module.css"
|
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) => {
|
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return json(
|
return json(
|
||||||
@@ -26,6 +74,7 @@ export function BlackSection() {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sessionAction = useAction(createSessionUrl)
|
const sessionAction = useAction(createSessionUrl)
|
||||||
const sessionSubmission = useSubmission(createSessionUrl)
|
const sessionSubmission = useSubmission(createSessionUrl)
|
||||||
|
const subscription = createAsync(() => querySubscription(params.id!))
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
sessionRedirecting: false,
|
sessionRedirecting: false,
|
||||||
})
|
})
|
||||||
@@ -53,6 +102,32 @@ export function BlackSection() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
|
|||||||
import { action, json, query } from "@solidjs/router"
|
import { action, json, query } from "@solidjs/router"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
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 { 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 { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||||
import { UserTable } from "@opencode-ai/console-core/schema/user.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 () => {
|
return withActor(async () => {
|
||||||
const billing = await Billing.get()
|
const billing = await Billing.get()
|
||||||
return {
|
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,
|
reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
|
||||||
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
|
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
|
||||||
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
|
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
|
||||||
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
|
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
|
||||||
|
monthlyLimit: billing.monthlyLimit,
|
||||||
|
monthlyUsage: billing.monthlyUsage,
|
||||||
|
timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
|
||||||
|
reloadError: billing.reloadError,
|
||||||
|
timeReloadError: billing.timeReloadError,
|
||||||
|
subscriptionID: billing.subscriptionID,
|
||||||
}
|
}
|
||||||
}, workspaceID)
|
}, workspaceID)
|
||||||
}, "billing.get")
|
}, "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 { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||||
import { ZenData } from "@opencode-ai/console-core/model.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 { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||||
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
||||||
@@ -495,27 +495,28 @@ export async function handler(
|
|||||||
|
|
||||||
// Check weekly limit
|
// Check weekly limit
|
||||||
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
||||||
const week = getWeekBounds(now)
|
const result = Black.analyzeWeeklyUsage({
|
||||||
if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
|
usage: sub.fixedUsage,
|
||||||
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
|
timeUpdated: sub.timeFixedUpdated,
|
||||||
|
})
|
||||||
|
if (result.status === "rate-limited")
|
||||||
throw new SubscriptionError(
|
throw new SubscriptionError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||||
retryAfter,
|
result.resetInSec,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rolling limit
|
// Check rolling limit
|
||||||
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
||||||
const rollingWindowMs = black.rollingWindow * 3600 * 1000
|
const result = Black.analyzeRollingUsage({
|
||||||
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
usage: sub.rollingUsage,
|
||||||
if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
|
timeUpdated: sub.timeRollingUpdated,
|
||||||
const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
|
})
|
||||||
|
if (result.status === "rate-limited")
|
||||||
throw new SubscriptionError(
|
throw new SubscriptionError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||||
retryAfter,
|
result.resetInSec,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"promote-models-to-dev": "script/promote-models.ts dev",
|
"promote-models-to-dev": "script/promote-models.ts dev",
|
||||||
"promote-models-to-prod": "script/promote-models.ts production",
|
"promote-models-to-prod": "script/promote-models.ts production",
|
||||||
"pull-models-from-dev": "script/pull-models.ts dev",
|
"pull-models-from-dev": "script/pull-models.ts dev",
|
||||||
|
"pull-models-from-prod": "script/pull-models.ts production",
|
||||||
"update-black": "script/update-black.ts",
|
"update-black": "script/update-black.ts",
|
||||||
"promote-black-to-dev": "script/promote-black.ts dev",
|
"promote-black-to-dev": "script/promote-black.ts dev",
|
||||||
"promote-black-to-prod": "script/promote-black.ts production",
|
"promote-black-to-prod": "script/promote-black.ts production",
|
||||||
|
|||||||
@@ -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 { 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
|
// get input from command line
|
||||||
const workspaceID = process.argv[2]
|
const workspaceID = process.argv[2]
|
||||||
@@ -9,6 +11,19 @@ if (!workspaceID || !dollarAmount) {
|
|||||||
process.exit(1)
|
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)
|
const amountInDollars = parseFloat(dollarAmount)
|
||||||
if (isNaN(amountInDollars) || amountInDollars <= 0) {
|
if (isNaN(amountInDollars) || amountInDollars <= 0) {
|
||||||
console.error("Error: dollarAmount must be a positive number")
|
console.error("Error: dollarAmount must be a positive number")
|
||||||
|
|||||||
@@ -113,8 +113,13 @@ async function printWorkspace(workspaceID: string) {
|
|||||||
.select({
|
.select({
|
||||||
balance: BillingTable.balance,
|
balance: BillingTable.balance,
|
||||||
customerID: BillingTable.customerID,
|
customerID: BillingTable.customerID,
|
||||||
subscriptionID: BillingTable.subscriptionID,
|
reload: BillingTable.reload,
|
||||||
subscriptionCouponID: BillingTable.subscriptionCouponID,
|
subscription: {
|
||||||
|
id: BillingTable.subscriptionID,
|
||||||
|
couponID: BillingTable.subscriptionCouponID,
|
||||||
|
plan: BillingTable.subscriptionPlan,
|
||||||
|
booked: BillingTable.timeSubscriptionBooked,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.from(BillingTable)
|
.from(BillingTable)
|
||||||
.where(eq(BillingTable.workspaceID, workspace.id))
|
.where(eq(BillingTable.workspaceID, workspace.id))
|
||||||
@@ -123,6 +128,11 @@ async function printWorkspace(workspaceID: string) {
|
|||||||
rows.map((row) => ({
|
rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
balance: `$${(row.balance / 100000000).toFixed(2)}`,
|
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],
|
}))[0],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,22 +25,7 @@ export namespace Billing {
|
|||||||
export const get = async () => {
|
export const get = async () => {
|
||||||
return Database.use(async (tx) =>
|
return Database.use(async (tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.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,
|
|
||||||
})
|
|
||||||
.from(BillingTable)
|
.from(BillingTable)
|
||||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||||
.then((r) => r[0]),
|
.then((r) => r[0]),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { centsToMicroCents } from "./util/price"
|
||||||
|
import { getWeekBounds } from "./util/date"
|
||||||
|
|
||||||
export namespace BlackData {
|
export namespace BlackData {
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
@@ -18,3 +20,73 @@ export namespace BlackData {
|
|||||||
return Schema.parse(json)
|
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<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>
|
<script src="/src/index.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ semver = "1.0.27"
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
uuid = { version = "1.19.0", features = ["v4"] }
|
uuid = { version = "1.19.0", features = ["v4"] }
|
||||||
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
gtk = "0.18.2"
|
gtk = "0.18.2"
|
||||||
webkit2gtk = "=2.0.1"
|
webkit2gtk = "=2.0.1"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-set-theme",
|
||||||
"core:webview:allow-set-webview-zoom",
|
"core:webview:allow-set-webview-zoom",
|
||||||
"core:window:allow-is-focused",
|
"core:window:allow-is-focused",
|
||||||
"core:window:allow-show",
|
"core:window:allow-show",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use std::{
|
|||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::{Duration, Instant},
|
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_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
@@ -237,7 +237,14 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_os::init())
|
.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_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
@@ -268,29 +275,30 @@ pub fn run() {
|
|||||||
.map(|m| m.size().to_logical(m.scale_factor()))
|
.map(|m| m.size().to_logical(m.scale_factor()))
|
||||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||||
|
|
||||||
#[allow(unused_mut)]
|
let config = app
|
||||||
let mut window_builder =
|
.config()
|
||||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
.app
|
||||||
.title("OpenCode")
|
.windows
|
||||||
.inner_size(size.width as f64, size.height as f64)
|
.iter()
|
||||||
.decorations(true)
|
.find(|w| w.label == "main")
|
||||||
.zoom_hotkeys_enabled(true)
|
.expect("main window config missing");
|
||||||
.disable_drag_drop_handler()
|
|
||||||
.initialization_script(format!(
|
let window_builder = WebviewWindowBuilder::from_config(&app, config)
|
||||||
r#"
|
.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__ ??= {{}};
|
||||||
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
||||||
"#
|
"#
|
||||||
));
|
));
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
let window_builder = window_builder
|
||||||
window_builder = window_builder
|
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
.hidden_title(true);
|
||||||
.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();
|
let (tx, rx) = oneshot::channel();
|
||||||
app.manage(ServerState::new(None, rx));
|
app.manage(ServerState::new(None, rx));
|
||||||
|
|||||||
@@ -11,6 +11,20 @@
|
|||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"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,
|
"withGlobalTauri": true,
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
|
|||||||
@@ -2,6 +2,27 @@
|
|||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "OpenCode",
|
"productName": "OpenCode",
|
||||||
"identifier": "ai.opencode.desktop",
|
"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": {
|
"bundle": {
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": true,
|
||||||
"icon": [
|
"icon": [
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
|
|||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
||||||
import { Store } from "@tauri-apps/plugin-store"
|
import { Store } from "@tauri-apps/plugin-store"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
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 { UPDATER_ENABLED } from "./updater"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
@@ -30,6 +30,11 @@ let update: Update | null = null
|
|||||||
|
|
||||||
const createPlatform = (password: Accessor<string | null>): Platform => ({
|
const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||||
platform: "desktop",
|
platform: "desktop",
|
||||||
|
os: (() => {
|
||||||
|
const type = ostype()
|
||||||
|
if (type === "macos" || type === "windows" || type === "linux") return type
|
||||||
|
return undefined
|
||||||
|
})(),
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
|
|
||||||
async openDirectoryPickerDialog(opts) {
|
async openDirectoryPickerDialog(opts) {
|
||||||
@@ -296,12 +301,24 @@ render(() => {
|
|||||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||||
const platform = createPlatform(() => serverPassword())
|
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 (
|
return (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<AppBaseProviders>
|
<AppBaseProviders>
|
||||||
{ostype() === "macos" && (
|
|
||||||
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
|
|
||||||
)}
|
|
||||||
<ServerGate>
|
<ServerGate>
|
||||||
{(data) => {
|
{(data) => {
|
||||||
setServerPassword(data().password)
|
setServerPassword(data().password)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.1.20"
|
version = "1.1.21"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -82,8 +82,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.72",
|
"@opentui/core": "0.1.73",
|
||||||
"@opentui/solid": "0.1.72",
|
"@opentui/solid": "0.1.73",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ export namespace Agent {
|
|||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
".opencode/plans/*.md": "allow",
|
[path.join(".opencode", "plans", "*.md")]: "allow",
|
||||||
[path.relative(Instance.worktree, path.join(Global.Path.data, "plans/*.md"))]: "allow",
|
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -338,9 +338,9 @@ export const AuthLoginCommand = cmd({
|
|||||||
prompts.log.info(
|
prompts.log.info(
|
||||||
"Amazon Bedrock authentication priority:\n" +
|
"Amazon Bedrock authentication priority:\n" +
|
||||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\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" +
|
"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).",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
|
|||||||
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||||
import { Keybind } from "@/util/keybind"
|
import { useKeybind } from "../context/keybind"
|
||||||
import * as fuzzysort from "fuzzysort"
|
import * as fuzzysort from "fuzzysort"
|
||||||
|
|
||||||
export function useConnected() {
|
export function useConnected() {
|
||||||
@@ -19,6 +19,7 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const keybind = useKeybind()
|
||||||
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
|
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("")
|
||||||
|
|
||||||
@@ -207,14 +208,14 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
<DialogSelect
|
<DialogSelect
|
||||||
keybind={[
|
keybind={[
|
||||||
{
|
{
|
||||||
keybind: Keybind.parse("ctrl+a")[0],
|
keybind: keybind.all.model_provider_list?.[0],
|
||||||
title: connected() ? "Connect provider" : "View all providers",
|
title: connected() ? "Connect provider" : "View all providers",
|
||||||
onTrigger() {
|
onTrigger() {
|
||||||
dialog.replace(() => <DialogProvider />)
|
dialog.replace(() => <DialogProvider />)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keybind: Keybind.parse("ctrl+f")[0],
|
keybind: keybind.all.model_favorite_toggle?.[0],
|
||||||
title: "Favorite",
|
title: "Favorite",
|
||||||
disabled: !connected(),
|
disabled: !connected(),
|
||||||
onTrigger: (option) => {
|
onTrigger: (option) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRoute } from "@tui/context/route"
|
|||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { Keybind } from "@/util/keybind"
|
import { useKeybind } from "../context/keybind"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useSDK } from "../context/sdk"
|
import { useSDK } from "../context/sdk"
|
||||||
import { DialogSessionRename } from "./dialog-session-rename"
|
import { DialogSessionRename } from "./dialog-session-rename"
|
||||||
@@ -14,9 +14,10 @@ import "opentui-spinner/solid"
|
|||||||
|
|
||||||
export function DialogSessionList() {
|
export function DialogSessionList() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const sync = useSync()
|
|
||||||
const { theme } = useTheme()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const sync = useSync()
|
||||||
|
const keybind = useKeybind()
|
||||||
|
const { theme } = useTheme()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
|
|
||||||
@@ -29,8 +30,6 @@ export function DialogSessionList() {
|
|||||||
return result.data ?? []
|
return result.data ?? []
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteKeybind = "ctrl+d"
|
|
||||||
|
|
||||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||||
|
|
||||||
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||||
@@ -52,7 +51,7 @@ export function DialogSessionList() {
|
|||||||
const status = sync.data.session_status?.[x.id]
|
const status = sync.data.session_status?.[x.id]
|
||||||
const isWorking = status?.type === "busy"
|
const isWorking = status?.type === "busy"
|
||||||
return {
|
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,
|
bg: isDeleting ? theme.error : undefined,
|
||||||
value: x.id,
|
value: x.id,
|
||||||
category,
|
category,
|
||||||
@@ -89,7 +88,7 @@ export function DialogSessionList() {
|
|||||||
}}
|
}}
|
||||||
keybind={[
|
keybind={[
|
||||||
{
|
{
|
||||||
keybind: Keybind.parse(deleteKeybind)[0],
|
keybind: keybind.all.session_delete?.[0],
|
||||||
title: "delete",
|
title: "delete",
|
||||||
onTrigger: async (option) => {
|
onTrigger: async (option) => {
|
||||||
if (toDelete() === option.value) {
|
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",
|
title: "rename",
|
||||||
onTrigger: async (option) => {
|
onTrigger: async (option) => {
|
||||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useDialog } from "@tui/ui/dialog"
|
|||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||||
import { createMemo, createSignal } from "solid-js"
|
import { createMemo, createSignal } from "solid-js"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { Keybind } from "@/util/keybind"
|
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
|
import { useKeybind } from "../context/keybind"
|
||||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
||||||
|
|
||||||
function getRelativeTime(timestamp: number): string {
|
function getRelativeTime(timestamp: number): string {
|
||||||
@@ -30,6 +30,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const keybind = useKeybind()
|
||||||
|
|
||||||
const [toDelete, setToDelete] = createSignal<number>()
|
const [toDelete, setToDelete] = createSignal<number>()
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
|||||||
const isDeleting = toDelete() === index
|
const isDeleting = toDelete() === index
|
||||||
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
|
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
|
||||||
return {
|
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,
|
bg: isDeleting ? theme.error : undefined,
|
||||||
value: index,
|
value: index,
|
||||||
description: getRelativeTime(entry.timestamp),
|
description: getRelativeTime(entry.timestamp),
|
||||||
@@ -69,7 +70,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
|||||||
}}
|
}}
|
||||||
keybind={[
|
keybind={[
|
||||||
{
|
{
|
||||||
keybind: Keybind.parse("ctrl+d")[0],
|
keybind: keybind.all.stash_delete?.[0],
|
||||||
title: "delete",
|
title: "delete",
|
||||||
onTrigger: (option) => {
|
onTrigger: (option) => {
|
||||||
if (toDelete() === option.value) {
|
if (toDelete() === option.value) {
|
||||||
|
|||||||
@@ -1,24 +1,85 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes, RGBA } from "@opentui/core"
|
||||||
import { For } from "solid-js"
|
import { For, type JSX } from "solid-js"
|
||||||
import { useTheme } from "@tui/context/theme"
|
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() {
|
export function Logo() {
|
||||||
const { theme } = useTheme()
|
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 (
|
return (
|
||||||
<box>
|
<box>
|
||||||
<For each={LOGO_LEFT}>
|
<For each={LOGO_LEFT}>
|
||||||
{(line, index) => (
|
{(line, index) => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={theme.textMuted} selectable={false}>
|
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
|
||||||
{line}
|
<box flexDirection="row">{renderLine(LOGO_RIGHT[index()], theme.text, true)}</box>
|
||||||
</text>
|
|
||||||
<text fg={theme.text} attributes={TextAttributes.BOLD} selectable={false}>
|
|
||||||
{LOGO_RIGHT[index()]}
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { FilePart } from "@opencode-ai/sdk/v2"
|
|||||||
import { TuiEvent } from "../../event"
|
import { TuiEvent } from "../../event"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
|
import { formatDuration } from "@/util/format"
|
||||||
import { createColors, createFrames } from "../../ui/spinner.ts"
|
import { createColors, createFrames } from "../../ui/spinner.ts"
|
||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
|
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
|
||||||
@@ -1037,7 +1038,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (!r) return ""
|
if (!r) return ""
|
||||||
const baseMessage = message()
|
const baseMessage = message()
|
||||||
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
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
|
return baseMessage + truncatedHint + retryInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -417,6 +417,13 @@ async function getCustomThemes() {
|
|||||||
return result
|
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 {
|
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||||
@@ -428,13 +435,6 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
|
|||||||
return ansiToRgba(i)
|
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
|
// Generate gray scale based on terminal background
|
||||||
const grays = generateGrayScale(bg, isDark)
|
const grays = generateGrayScale(bg, isDark)
|
||||||
const textMuted = generateMutedTextColor(bg, isDark)
|
const textMuted = generateMutedTextColor(bg, isDark)
|
||||||
|
|||||||
@@ -237,17 +237,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||||||
<Show when={diff().length <= 2 || expanded.diff}>
|
<Show when={diff().length <= 2 || expanded.diff}>
|
||||||
<For each={diff() || []}>
|
<For each={diff() || []}>
|
||||||
{(item) => {
|
{(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 (
|
return (
|
||||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||||
<text fg={theme.textMuted} wrapMode="char">
|
<text fg={theme.textMuted} truncate={true} wrapMode="none">
|
||||||
{file()}
|
{item.file}
|
||||||
</text>
|
</text>
|
||||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||||
<Show when={item.additions}>
|
<Show when={item.additions}>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface DialogSelectProps<T> {
|
|||||||
onSelect?: (option: DialogSelectOption<T>) => void
|
onSelect?: (option: DialogSelectOption<T>) => void
|
||||||
skipFilter?: boolean
|
skipFilter?: boolean
|
||||||
keybind?: {
|
keybind?: {
|
||||||
keybind: Keybind.Info
|
keybind?: Keybind.Info
|
||||||
title: string
|
title: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onTrigger: (option: DialogSelectOption<T>) => void
|
onTrigger: (option: DialogSelectOption<T>) => void
|
||||||
@@ -109,15 +109,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on([() => store.filter, () => props.current], ([filter, current]) => {
|
on([() => store.filter, () => props.current], ([filter, current]) => {
|
||||||
if (filter.length > 0) {
|
setTimeout(() => {
|
||||||
setStore("selected", 0)
|
if (filter.length > 0) {
|
||||||
} else if (current) {
|
moveTo(0, true)
|
||||||
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
|
} else if (current) {
|
||||||
if (currentIndex >= 0) {
|
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
|
||||||
setStore("selected", currentIndex)
|
if (currentIndex >= 0) {
|
||||||
|
moveTo(currentIndex, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}, 0)
|
||||||
scroll?.scrollTo(0)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
moveTo(next)
|
moveTo(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveTo(next: number) {
|
function moveTo(next: number, center = false) {
|
||||||
setStore("selected", next)
|
setStore("selected", next)
|
||||||
props.onMove?.(selected()!)
|
props.onMove?.(selected()!)
|
||||||
if (!scroll) return
|
if (!scroll) return
|
||||||
@@ -138,13 +139,18 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
})
|
})
|
||||||
if (!target) return
|
if (!target) return
|
||||||
const y = target.y - scroll.y
|
const y = target.y - scroll.y
|
||||||
if (y >= scroll.height) {
|
if (center) {
|
||||||
scroll.scrollBy(y - scroll.height + 1)
|
const centerOffset = Math.floor(scroll.height / 2)
|
||||||
}
|
scroll.scrollBy(y - centerOffset)
|
||||||
if (y < 0) {
|
} else {
|
||||||
scroll.scrollBy(y)
|
if (y >= scroll.height) {
|
||||||
if (isDeepEqual(flat()[0].value, selected()?.value)) {
|
scroll.scrollBy(y - scroll.height + 1)
|
||||||
scroll.scrollTo(0)
|
}
|
||||||
|
if (y < 0) {
|
||||||
|
scroll.scrollBy(y)
|
||||||
|
if (isDeepEqual(flat()[0].value, selected()?.value)) {
|
||||||
|
scroll.scrollTo(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +172,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of props.keybind ?? []) {
|
for (const item of props.keybind ?? []) {
|
||||||
if (item.disabled) continue
|
if (item.disabled || !item.keybind) continue
|
||||||
if (Keybind.match(item.keybind, keybind.parse(evt))) {
|
if (Keybind.match(item.keybind, keybind.parse(evt))) {
|
||||||
const s = selected()
|
const s = selected()
|
||||||
if (s) {
|
if (s) {
|
||||||
@@ -188,7 +194,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
}
|
}
|
||||||
props.ref?.(ref)
|
props.ref?.(ref)
|
||||||
|
|
||||||
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled) ?? [])
|
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box gap={1} paddingBottom={1}>
|
<box gap={1} paddingBottom={1}>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function FormatError(input: unknown) {
|
|||||||
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
|
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
|
||||||
}
|
}
|
||||||
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
|
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
|
||||||
return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
|
return input.data.message
|
||||||
}
|
}
|
||||||
if (Config.InvalidError.isInstance(input))
|
if (Config.InvalidError.isInstance(input))
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { BunProc } from "@/bun"
|
|||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { ConfigMarkdown } from "./markdown"
|
import { ConfigMarkdown } from "./markdown"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { Session } from "@/session"
|
||||||
|
|
||||||
export namespace Config {
|
export namespace Config {
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
@@ -231,8 +233,15 @@ export namespace Config {
|
|||||||
dot: true,
|
dot: true,
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
})) {
|
})) {
|
||||||
const md = await ConfigMarkdown.parse(item)
|
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||||
if (!md.data) continue
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse command ${item}`
|
||||||
|
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load command", { command: item, err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!md) continue
|
||||||
|
|
||||||
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
|
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
|
||||||
const file = rel(item, patterns) ?? path.basename(item)
|
const file = rel(item, patterns) ?? path.basename(item)
|
||||||
@@ -263,8 +272,15 @@ export namespace Config {
|
|||||||
dot: true,
|
dot: true,
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
})) {
|
})) {
|
||||||
const md = await ConfigMarkdown.parse(item)
|
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||||
if (!md.data) continue
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse agent ${item}`
|
||||||
|
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load agent", { agent: item, err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!md) continue
|
||||||
|
|
||||||
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
|
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
|
||||||
const file = rel(item, patterns) ?? path.basename(item)
|
const file = rel(item, patterns) ?? path.basename(item)
|
||||||
@@ -294,8 +310,15 @@ export namespace Config {
|
|||||||
dot: true,
|
dot: true,
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
})) {
|
})) {
|
||||||
const md = await ConfigMarkdown.parse(item)
|
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||||
if (!md.data) continue
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse mode ${item}`
|
||||||
|
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load mode", { mode: item, err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!md) continue
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
name: path.basename(item, ".md"),
|
name: path.basename(item, ".md"),
|
||||||
@@ -395,9 +418,7 @@ export namespace Config {
|
|||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
|
||||||
"Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.",
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.meta({
|
.meta({
|
||||||
@@ -436,9 +457,7 @@ export namespace Config {
|
|||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
|
||||||
"Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.",
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.meta({
|
.meta({
|
||||||
@@ -621,7 +640,11 @@ export namespace Config {
|
|||||||
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
||||||
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
|
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
|
||||||
session_fork: z.string().optional().default("none").describe("Fork session from message"),
|
session_fork: z.string().optional().default("none").describe("Fork session from message"),
|
||||||
session_rename: z.string().optional().default("none").describe("Rename session"),
|
session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
|
||||||
|
session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
|
||||||
|
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
|
||||||
|
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
|
||||||
|
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
|
||||||
session_share: z.string().optional().default("none").describe("Share current session"),
|
session_share: z.string().optional().default("none").describe("Share current session"),
|
||||||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||||
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
||||||
|
|||||||
@@ -14,8 +14,60 @@ export namespace ConfigMarkdown {
|
|||||||
return Array.from(template.matchAll(SHELL_REGEX))
|
return Array.from(template.matchAll(SHELL_REGEX))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function preprocessFrontmatter(content: string): string {
|
||||||
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||||
|
if (!match) return content
|
||||||
|
|
||||||
|
const frontmatter = match[1]
|
||||||
|
const lines = frontmatter.split("\n")
|
||||||
|
const result: string[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// skip comments and empty lines
|
||||||
|
if (line.trim().startsWith("#") || line.trim() === "") {
|
||||||
|
result.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip lines that are continuations (indented)
|
||||||
|
if (line.match(/^\s+/)) {
|
||||||
|
result.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// match key: value pattern
|
||||||
|
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
|
||||||
|
if (!kvMatch) {
|
||||||
|
result.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = kvMatch[1]
|
||||||
|
const value = kvMatch[2].trim()
|
||||||
|
|
||||||
|
// skip if value is empty, already quoted, or uses block scalar
|
||||||
|
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
|
||||||
|
result.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// if value contains a colon, convert to block scalar
|
||||||
|
if (value.includes(":")) {
|
||||||
|
result.push(`${key}: |`)
|
||||||
|
result.push(` ${value}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
const processed = result.join("\n")
|
||||||
|
return content.replace(frontmatter, () => processed)
|
||||||
|
}
|
||||||
|
|
||||||
export async function parse(filePath: string) {
|
export async function parse(filePath: string) {
|
||||||
const template = await Bun.file(filePath).text()
|
const raw = await Bun.file(filePath).text()
|
||||||
|
const template = preprocessFrontmatter(raw)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const md = matter(template)
|
const md = matter(template)
|
||||||
@@ -24,7 +76,7 @@ export namespace ConfigMarkdown {
|
|||||||
throw new FrontmatterError(
|
throw new FrontmatterError(
|
||||||
{
|
{
|
||||||
path: filePath,
|
path: filePath,
|
||||||
message: `Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
|
message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
},
|
},
|
||||||
{ cause: err },
|
{ cause: err },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ await Promise.all([
|
|||||||
fs.mkdir(Global.Path.bin, { recursive: true }),
|
fs.mkdir(Global.Path.bin, { recursive: true }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const CACHE_VERSION = "17"
|
const CACHE_VERSION = "18"
|
||||||
|
|
||||||
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
||||||
.text()
|
.text()
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export namespace MCP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert MCP tool definition to AI SDK Tool type
|
// Convert MCP tool definition to AI SDK Tool type
|
||||||
async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Promise<Tool> {
|
async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Promise<Tool> {
|
||||||
const inputSchema = mcpTool.inputSchema
|
const inputSchema = mcpTool.inputSchema
|
||||||
|
|
||||||
// Spread first, then override type to ensure it's always "object"
|
// Spread first, then override type to ensure it's always "object"
|
||||||
@@ -119,7 +119,6 @@ export namespace MCP {
|
|||||||
properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
|
properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
}
|
}
|
||||||
const config = await Config.get()
|
|
||||||
|
|
||||||
return dynamicTool({
|
return dynamicTool({
|
||||||
description: mcpTool.description ?? "",
|
description: mcpTool.description ?? "",
|
||||||
@@ -133,7 +132,7 @@ export namespace MCP {
|
|||||||
CallToolResultSchema,
|
CallToolResultSchema,
|
||||||
{
|
{
|
||||||
resetTimeoutOnProgress: true,
|
resetTimeoutOnProgress: true,
|
||||||
timeout: config.experimental?.mcp_timeout,
|
timeout,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -556,7 +555,10 @@ export namespace MCP {
|
|||||||
export async function tools() {
|
export async function tools() {
|
||||||
const result: Record<string, Tool> = {}
|
const result: Record<string, Tool> = {}
|
||||||
const s = await state()
|
const s = await state()
|
||||||
|
const cfg = await Config.get()
|
||||||
|
const config = cfg.mcp ?? {}
|
||||||
const clientsSnapshot = await clients()
|
const clientsSnapshot = await clients()
|
||||||
|
const defaultTimeout = cfg.experimental?.mcp_timeout
|
||||||
|
|
||||||
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
|
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
|
||||||
// Only include tools from connected MCPs (skip disabled ones)
|
// Only include tools from connected MCPs (skip disabled ones)
|
||||||
@@ -577,10 +579,13 @@ export namespace MCP {
|
|||||||
if (!toolsResult) {
|
if (!toolsResult) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
const mcpConfig = config[clientName]
|
||||||
|
const entry = isMcpConfigured(mcpConfig) ? mcpConfig : undefined
|
||||||
|
const timeout = entry?.timeout ?? defaultTimeout
|
||||||
for (const mcpTool of toolsResult.tools) {
|
for (const mcpTool of toolsResult.tools) {
|
||||||
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||||
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||||
result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client)
|
result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client, timeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -361,38 +361,6 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!provider.models["gpt-5.2-codex"]) {
|
|
||||||
const model = {
|
|
||||||
id: "gpt-5.2-codex",
|
|
||||||
providerID: "openai",
|
|
||||||
api: {
|
|
||||||
id: "gpt-5.2-codex",
|
|
||||||
url: "https://chatgpt.com/backend-api/codex",
|
|
||||||
npm: "@ai-sdk/openai",
|
|
||||||
},
|
|
||||||
name: "GPT-5.2 Codex",
|
|
||||||
capabilities: {
|
|
||||||
temperature: false,
|
|
||||||
reasoning: true,
|
|
||||||
attachment: true,
|
|
||||||
toolcall: true,
|
|
||||||
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
|
||||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
||||||
interleaved: false,
|
|
||||||
},
|
|
||||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
||||||
limit: { context: 400000, output: 128000 },
|
|
||||||
status: "active" as const,
|
|
||||||
options: {},
|
|
||||||
headers: {},
|
|
||||||
release_date: "2025-12-18",
|
|
||||||
variants: {} as Record<string, Record<string, any>>,
|
|
||||||
family: "gpt-codex",
|
|
||||||
}
|
|
||||||
model.variants = ProviderTransform.variants(model)
|
|
||||||
provider.models["gpt-5.2-codex"] = model
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zero out costs for Codex (included with ChatGPT subscription)
|
// Zero out costs for Codex (included with ChatGPT subscription)
|
||||||
for (const model of Object.values(provider.models)) {
|
for (const model of Object.values(provider.models)) {
|
||||||
model.cost = {
|
model.cost = {
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { Installation } from "@/installation"
|
||||||
|
import { iife } from "@/util/iife"
|
||||||
|
|
||||||
|
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
||||||
|
|
||||||
|
function normalizeDomain(url: string) {
|
||||||
|
return url.replace(/^https?:\/\//, "").replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrls(domain: string) {
|
||||||
|
return {
|
||||||
|
DEVICE_CODE_URL: `https://${domain}/login/device/code`,
|
||||||
|
ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||||
|
return {
|
||||||
|
auth: {
|
||||||
|
provider: "github-copilot",
|
||||||
|
async loader(getAuth, provider) {
|
||||||
|
const info = await getAuth()
|
||||||
|
if (!info || info.type !== "oauth") return {}
|
||||||
|
|
||||||
|
if (provider && provider.models) {
|
||||||
|
for (const model of Object.values(provider.models)) {
|
||||||
|
model.cost = {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterpriseUrl = info.enterpriseUrl
|
||||||
|
const baseURL = enterpriseUrl
|
||||||
|
? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
|
||||||
|
: "https://api.githubcopilot.com"
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseURL,
|
||||||
|
apiKey: "",
|
||||||
|
async fetch(request: RequestInfo | URL, init?: RequestInit) {
|
||||||
|
const info = await getAuth()
|
||||||
|
if (info.type !== "oauth") return fetch(request, init)
|
||||||
|
|
||||||
|
const { isVision, isAgent } = iife(() => {
|
||||||
|
try {
|
||||||
|
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body
|
||||||
|
|
||||||
|
// Completions API
|
||||||
|
if (body?.messages) {
|
||||||
|
const last = body.messages[body.messages.length - 1]
|
||||||
|
return {
|
||||||
|
isVision: body.messages.some(
|
||||||
|
(msg: any) =>
|
||||||
|
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
||||||
|
),
|
||||||
|
isAgent: last?.role !== "user",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responses API
|
||||||
|
if (body?.input) {
|
||||||
|
const last = body.input[body.input.length - 1]
|
||||||
|
return {
|
||||||
|
isVision: body.input.some(
|
||||||
|
(item: any) =>
|
||||||
|
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
|
||||||
|
),
|
||||||
|
isAgent: last?.role !== "user",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { isVision: false, isAgent: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(init?.headers as Record<string, string>),
|
||||||
|
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||||
|
Authorization: `Bearer ${info.refresh}`,
|
||||||
|
"Openai-Intent": "conversation-edits",
|
||||||
|
"X-Initiator": isAgent ? "agent" : "user",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVision) {
|
||||||
|
headers["Copilot-Vision-Request"] = "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
delete headers["x-api-key"]
|
||||||
|
delete headers["authorization"]
|
||||||
|
|
||||||
|
return fetch(request, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
type: "oauth",
|
||||||
|
label: "Login with GitHub Copilot",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "deploymentType",
|
||||||
|
message: "Select GitHub deployment type",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "GitHub.com",
|
||||||
|
value: "github.com",
|
||||||
|
hint: "Public",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "GitHub Enterprise",
|
||||||
|
value: "enterprise",
|
||||||
|
hint: "Data residency or self-hosted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
key: "enterpriseUrl",
|
||||||
|
message: "Enter your GitHub Enterprise URL or domain",
|
||||||
|
placeholder: "company.ghe.com or https://company.ghe.com",
|
||||||
|
condition: (inputs) => inputs.deploymentType === "enterprise",
|
||||||
|
validate: (value) => {
|
||||||
|
if (!value) return "URL or domain is required"
|
||||||
|
try {
|
||||||
|
const url = value.includes("://") ? new URL(value) : new URL(`https://${value}`)
|
||||||
|
if (!url.hostname) return "Please enter a valid URL or domain"
|
||||||
|
return undefined
|
||||||
|
} catch {
|
||||||
|
return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
async authorize(inputs = {}) {
|
||||||
|
const deploymentType = inputs.deploymentType || "github.com"
|
||||||
|
|
||||||
|
let domain = "github.com"
|
||||||
|
let actualProvider = "github-copilot"
|
||||||
|
|
||||||
|
if (deploymentType === "enterprise") {
|
||||||
|
const enterpriseUrl = inputs.enterpriseUrl
|
||||||
|
domain = normalizeDomain(enterpriseUrl!)
|
||||||
|
actualProvider = "github-copilot-enterprise"
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = getUrls(domain)
|
||||||
|
|
||||||
|
const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
scope: "read:user",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!deviceResponse.ok) {
|
||||||
|
throw new Error("Failed to initiate device authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceData = (await deviceResponse.json()) as {
|
||||||
|
verification_uri: string
|
||||||
|
user_code: string
|
||||||
|
device_code: string
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: deviceData.verification_uri,
|
||||||
|
instructions: `Enter code: ${deviceData.user_code}`,
|
||||||
|
method: "auto" as const,
|
||||||
|
async callback() {
|
||||||
|
while (true) {
|
||||||
|
const response = await fetch(urls.ACCESS_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
device_code: deviceData.device_code,
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) return { type: "failed" as const }
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
access_token?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.access_token) {
|
||||||
|
const result: {
|
||||||
|
type: "success"
|
||||||
|
refresh: string
|
||||||
|
access: string
|
||||||
|
expires: number
|
||||||
|
provider?: string
|
||||||
|
enterpriseUrl?: string
|
||||||
|
} = {
|
||||||
|
type: "success",
|
||||||
|
refresh: data.access_token,
|
||||||
|
access: data.access_token,
|
||||||
|
expires: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualProvider === "github-copilot-enterprise") {
|
||||||
|
result.provider = "github-copilot-enterprise"
|
||||||
|
result.enterpriseUrl = domain
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error === "authorization_pending") {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error) return { type: "failed" as const }
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,18 +10,15 @@ import { Flag } from "../flag/flag"
|
|||||||
import { CodexAuthPlugin } from "./codex"
|
import { CodexAuthPlugin } from "./codex"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
|
import { CopilotAuthPlugin } from "./copilot"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
||||||
const BUILTIN = [
|
const BUILTIN = ["opencode-anthropic-auth@0.0.9", "@gitlab/opencode-gitlab-auth@1.3.0"]
|
||||||
"opencode-copilot-auth@0.0.12",
|
|
||||||
"opencode-anthropic-auth@0.0.8",
|
|
||||||
"@gitlab/opencode-gitlab-auth@1.3.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
// Built-in plugins that are directly imported (not installed from npm)
|
// Built-in plugins that are directly imported (not installed from npm)
|
||||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
|
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin]
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
const state = Instance.state(async () => {
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
@@ -53,7 +50,7 @@ export namespace Plugin {
|
|||||||
|
|
||||||
for (let plugin of plugins) {
|
for (let plugin of plugins) {
|
||||||
// ignore old codex plugin since it is supported first party now
|
// ignore old codex plugin since it is supported first party now
|
||||||
if (plugin.includes("opencode-openai-codex-auth")) continue
|
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
||||||
log.info("loading plugin", { path: plugin })
|
log.info("loading plugin", { path: plugin })
|
||||||
if (!plugin.startsWith("file://")) {
|
if (!plugin.startsWith("file://")) {
|
||||||
const lastAtIndex = plugin.lastIndexOf("@")
|
const lastAtIndex = plugin.lastIndexOf("@")
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export namespace ModelsDev {
|
|||||||
.optional(),
|
.optional(),
|
||||||
limit: z.object({
|
limit: z.object({
|
||||||
context: z.number(),
|
context: z.number(),
|
||||||
|
input: z.number().optional(),
|
||||||
output: z.number(),
|
output: z.number(),
|
||||||
}),
|
}),
|
||||||
modalities: z
|
modalities: z
|
||||||
|
|||||||
@@ -197,16 +197,23 @@ export namespace Provider {
|
|||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!profile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
|
const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
|
||||||
|
|
||||||
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
|
if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile) return { autoload: false }
|
||||||
|
|
||||||
// Build credential provider options (only pass profile if specified)
|
|
||||||
const credentialProviderOptions = profile ? { profile } : {}
|
|
||||||
|
|
||||||
const providerOptions: AmazonBedrockProviderSettings = {
|
const providerOptions: AmazonBedrockProviderSettings = {
|
||||||
region: defaultRegion,
|
region: defaultRegion,
|
||||||
credentialProvider: fromNodeProviderChain(credentialProviderOptions),
|
}
|
||||||
|
|
||||||
|
// Only use credential chain if no bearer token exists
|
||||||
|
// Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
|
||||||
|
if (!awsBearerToken) {
|
||||||
|
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
|
||||||
|
|
||||||
|
// Build credential provider options (only pass profile if specified)
|
||||||
|
const credentialProviderOptions = profile ? { profile } : {}
|
||||||
|
|
||||||
|
providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom endpoint if specified (endpoint takes precedence over baseURL)
|
// Add custom endpoint if specified (endpoint takes precedence over baseURL)
|
||||||
@@ -392,7 +399,7 @@ export namespace Provider {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async gitlab(input) {
|
gitlab: async (input) => {
|
||||||
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
|
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
|
||||||
|
|
||||||
const auth = await Auth.get(input.id)
|
const auth = await Auth.get(input.id)
|
||||||
@@ -550,6 +557,7 @@ export namespace Provider {
|
|||||||
}),
|
}),
|
||||||
limit: z.object({
|
limit: z.object({
|
||||||
context: z.number(),
|
context: z.number(),
|
||||||
|
input: z.number().optional(),
|
||||||
output: z.number(),
|
output: z.number(),
|
||||||
}),
|
}),
|
||||||
status: z.enum(["alpha", "beta", "deprecated", "active"]),
|
status: z.enum(["alpha", "beta", "deprecated", "active"]),
|
||||||
@@ -612,6 +620,7 @@ export namespace Provider {
|
|||||||
},
|
},
|
||||||
limit: {
|
limit: {
|
||||||
context: model.limit.context,
|
context: model.limit.context,
|
||||||
|
input: model.limit.input,
|
||||||
output: model.limit.output,
|
output: model.limit.output,
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -863,7 +872,12 @@ export namespace Provider {
|
|||||||
|
|
||||||
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
const result = await fn(database[providerID])
|
const data = database[providerID]
|
||||||
|
if (!data) {
|
||||||
|
log.error("Provider does not exist in model list " + providerID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const result = await fn(data)
|
||||||
if (result && (result.autoload || providers[providerID])) {
|
if (result && (result.autoload || providers[providerID])) {
|
||||||
if (result.getModel) modelLoaders[providerID] = result.getModel
|
if (result.getModel) modelLoaders[providerID] = result.getModel
|
||||||
mergeProvider(providerID, {
|
mergeProvider(providerID, {
|
||||||
|
|||||||
@@ -16,7 +16,31 @@ function mimeToModality(mime: string): Modality | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace ProviderTransform {
|
export namespace ProviderTransform {
|
||||||
function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
|
function normalizeMessages(
|
||||||
|
msgs: ModelMessage[],
|
||||||
|
model: Provider.Model,
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
): ModelMessage[] {
|
||||||
|
// Strip openai itemId metadata following what codex does
|
||||||
|
if (model.api.npm === "@ai-sdk/openai" || options.store === false) {
|
||||||
|
msgs = msgs.map((msg) => {
|
||||||
|
if (!Array.isArray(msg.content)) return msg
|
||||||
|
const content = msg.content.map((part) => {
|
||||||
|
if (!part.providerOptions?.openai) return part
|
||||||
|
const { itemId, reasoningEncryptedContent, ...rest } = part.providerOptions.openai as Record<string, unknown>
|
||||||
|
const openai = Object.keys(rest).length > 0 ? rest : undefined
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
providerOptions: {
|
||||||
|
...part.providerOptions,
|
||||||
|
openai,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { ...msg, content } as typeof msg
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Anthropic rejects messages with empty content - filter out empty string messages
|
// Anthropic rejects messages with empty content - filter out empty string messages
|
||||||
// and remove empty text/reasoning parts from array content
|
// and remove empty text/reasoning parts from array content
|
||||||
if (model.api.npm === "@ai-sdk/anthropic") {
|
if (model.api.npm === "@ai-sdk/anthropic") {
|
||||||
@@ -218,9 +242,9 @@ export namespace ProviderTransform {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function message(msgs: ModelMessage[], model: Provider.Model) {
|
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
|
||||||
msgs = unsupportedParts(msgs, model)
|
msgs = unsupportedParts(msgs, model)
|
||||||
msgs = normalizeMessages(msgs, model)
|
msgs = normalizeMessages(msgs, model, options)
|
||||||
if (
|
if (
|
||||||
model.providerID === "anthropic" ||
|
model.providerID === "anthropic" ||
|
||||||
model.api.id.includes("anthropic") ||
|
model.api.id.includes("anthropic") ||
|
||||||
@@ -453,64 +477,69 @@ export namespace ProviderTransform {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function options(
|
export function options(input: {
|
||||||
model: Provider.Model,
|
model: Provider.Model
|
||||||
sessionID: string,
|
sessionID: string
|
||||||
providerOptions?: Record<string, any>,
|
providerOptions?: Record<string, any>
|
||||||
): Record<string, any> {
|
}): Record<string, any> {
|
||||||
const result: Record<string, any> = {}
|
const result: Record<string, any> = {}
|
||||||
|
|
||||||
if (model.api.npm === "@openrouter/ai-sdk-provider") {
|
// openai and providers using openai package should set store to false by default.
|
||||||
|
if (input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai") {
|
||||||
|
result["store"] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.model.api.npm === "@openrouter/ai-sdk-provider") {
|
||||||
result["usage"] = {
|
result["usage"] = {
|
||||||
include: true,
|
include: true,
|
||||||
}
|
}
|
||||||
if (model.api.id.includes("gemini-3")) {
|
if (input.model.api.id.includes("gemini-3")) {
|
||||||
result["reasoning"] = { effort: "high" }
|
result["reasoning"] = { effort: "high" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
model.providerID === "baseten" ||
|
input.model.providerID === "baseten" ||
|
||||||
(model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(model.api.id))
|
(input.model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(input.model.api.id))
|
||||||
) {
|
) {
|
||||||
result["chat_template_args"] = { enable_thinking: true }
|
result["chat_template_args"] = { enable_thinking: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["zai", "zhipuai"].includes(model.providerID) && model.api.npm === "@ai-sdk/openai-compatible") {
|
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") {
|
||||||
result["thinking"] = {
|
result["thinking"] = {
|
||||||
type: "enabled",
|
type: "enabled",
|
||||||
clear_thinking: false,
|
clear_thinking: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.providerID === "openai" || providerOptions?.setCacheKey) {
|
if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) {
|
||||||
result["promptCacheKey"] = sessionID
|
result["promptCacheKey"] = input.sessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.api.npm === "@ai-sdk/google" || model.api.npm === "@ai-sdk/google-vertex") {
|
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
|
||||||
result["thinkingConfig"] = {
|
result["thinkingConfig"] = {
|
||||||
includeThoughts: true,
|
includeThoughts: true,
|
||||||
}
|
}
|
||||||
if (model.api.id.includes("gemini-3")) {
|
if (input.model.api.id.includes("gemini-3")) {
|
||||||
result["thinkingConfig"]["thinkingLevel"] = "high"
|
result["thinkingConfig"]["thinkingLevel"] = "high"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.api.id.includes("gpt-5") && !model.api.id.includes("gpt-5-chat")) {
|
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
|
||||||
if (model.providerID.includes("codex")) {
|
if (input.model.providerID.includes("codex")) {
|
||||||
result["store"] = false
|
result["store"] = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!model.api.id.includes("codex") && !model.api.id.includes("gpt-5-pro")) {
|
if (!input.model.api.id.includes("codex") && !input.model.api.id.includes("gpt-5-pro")) {
|
||||||
result["reasoningEffort"] = "medium"
|
result["reasoningEffort"] = "medium"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.api.id.endsWith("gpt-5.") && model.providerID !== "azure") {
|
if (input.model.api.id.endsWith("gpt-5.") && input.model.providerID !== "azure") {
|
||||||
result["textVerbosity"] = "low"
|
result["textVerbosity"] = "low"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.providerID.startsWith("opencode")) {
|
if (input.model.providerID.startsWith("opencode")) {
|
||||||
result["promptCacheKey"] = sessionID
|
result["promptCacheKey"] = input.sessionID
|
||||||
result["include"] = ["reasoning.encrypted_content"]
|
result["include"] = ["reasoning.encrypted_content"]
|
||||||
result["reasoningSummary"] = "auto"
|
result["reasoningSummary"] = "auto"
|
||||||
}
|
}
|
||||||
@@ -668,7 +697,10 @@ export namespace ProviderTransform {
|
|||||||
|
|
||||||
export function error(providerID: string, error: APICallError) {
|
export function error(providerID: string, error: APICallError) {
|
||||||
let message = error.message
|
let message = error.message
|
||||||
if (providerID === "github-copilot" && message.includes("The requested model is not supported")) {
|
if (providerID.includes("github-copilot") && error.statusCode === 403) {
|
||||||
|
return "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode."
|
||||||
|
}
|
||||||
|
if (providerID.includes("github-copilot") && message.includes("The requested model is not supported")) {
|
||||||
return (
|
return (
|
||||||
message +
|
message +
|
||||||
"\n\nMake sure the model is enabled in your copilot settings: https://github.com/settings/copilot/features"
|
"\n\nMake sure the model is enabled in your copilot settings: https://github.com/settings/copilot/features"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export namespace SessionCompaction {
|
|||||||
if (context === 0) return false
|
if (context === 0) return false
|
||||||
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
|
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
|
||||||
const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX
|
const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX
|
||||||
const usable = context - output
|
const usable = input.model.limit.input || context - output
|
||||||
return count > usable
|
return count > usable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ export namespace LLM {
|
|||||||
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
||||||
const base = input.small
|
const base = input.small
|
||||||
? ProviderTransform.smallOptions(input.model)
|
? ProviderTransform.smallOptions(input.model)
|
||||||
: ProviderTransform.options(input.model, input.sessionID, provider.options)
|
: ProviderTransform.options({
|
||||||
|
model: input.model,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
providerOptions: provider.options,
|
||||||
|
})
|
||||||
const options: Record<string, any> = pipe(
|
const options: Record<string, any> = pipe(
|
||||||
base,
|
base,
|
||||||
mergeDeep(input.model.options),
|
mergeDeep(input.model.options),
|
||||||
@@ -104,7 +108,6 @@ export namespace LLM {
|
|||||||
)
|
)
|
||||||
if (isCodex) {
|
if (isCodex) {
|
||||||
options.instructions = SystemPrompt.instructions()
|
options.instructions = SystemPrompt.instructions()
|
||||||
options.store = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = await Plugin.trigger(
|
const params = await Plugin.trigger(
|
||||||
@@ -214,7 +217,7 @@ export namespace LLM {
|
|||||||
async transformParams(args) {
|
async transformParams(args) {
|
||||||
if (args.type === "stream") {
|
if (args.type === "stream") {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model)
|
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
|
||||||
}
|
}
|
||||||
return args.params
|
return args.params
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import path from "path"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
@@ -6,8 +7,10 @@ import { ConfigMarkdown } from "../config/markdown"
|
|||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { exists } from "fs/promises"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||||
|
import { Session } from "@/session"
|
||||||
|
|
||||||
export namespace Skill {
|
export namespace Skill {
|
||||||
const log = Log.create({ service: "skill" })
|
const log = Log.create({ service: "skill" })
|
||||||
@@ -43,10 +46,16 @@ export namespace Skill {
|
|||||||
const skills: Record<string, Info> = {}
|
const skills: Record<string, Info> = {}
|
||||||
|
|
||||||
const addSkill = async (match: string) => {
|
const addSkill = async (match: string) => {
|
||||||
const md = await ConfigMarkdown.parse(match)
|
const md = await ConfigMarkdown.parse(match).catch((err) => {
|
||||||
if (!md) {
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
return
|
? err.data.message
|
||||||
}
|
: `Failed to parse skill ${match}`
|
||||||
|
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load skill", { skill: match, err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!md) return
|
||||||
|
|
||||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||||
if (!parsed.success) return
|
if (!parsed.success) return
|
||||||
@@ -77,7 +86,7 @@ export namespace Skill {
|
|||||||
)
|
)
|
||||||
// Also include global ~/.claude/skills/
|
// Also include global ~/.claude/skills/
|
||||||
const globalClaude = `${Global.Path.home}/.claude`
|
const globalClaude = `${Global.Path.home}/.claude`
|
||||||
if (await exists(globalClaude)) {
|
if (await Filesystem.isDir(globalClaude)) {
|
||||||
claudeDirs.push(globalClaude)
|
claudeDirs.push(globalClaude)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Log } from "../util/log"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { lazy } from "../util/lazy"
|
import { lazy } from "../util/lazy"
|
||||||
import { Lock } from "../util/lock"
|
import { Lock } from "../util/lock"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
@@ -23,7 +24,7 @@ export namespace Storage {
|
|||||||
const MIGRATIONS: Migration[] = [
|
const MIGRATIONS: Migration[] = [
|
||||||
async (dir) => {
|
async (dir) => {
|
||||||
const project = path.resolve(dir, "../project")
|
const project = path.resolve(dir, "../project")
|
||||||
if (!fs.exists(project)) return
|
if (!(await Filesystem.isDir(project))) return
|
||||||
for await (const projectDir of new Bun.Glob("*").scan({
|
for await (const projectDir of new Bun.Glob("*").scan({
|
||||||
cwd: project,
|
cwd: project,
|
||||||
onlyFiles: false,
|
onlyFiles: false,
|
||||||
@@ -43,7 +44,7 @@ export namespace Storage {
|
|||||||
if (worktree) break
|
if (worktree) break
|
||||||
}
|
}
|
||||||
if (!worktree) continue
|
if (!worktree) continue
|
||||||
if (!(await fs.exists(worktree))) continue
|
if (!(await Filesystem.isDir(worktree))) continue
|
||||||
const [id] = await $`git rev-list --max-parents=0 --all`
|
const [id] = await $`git rev-list --max-parents=0 --all`
|
||||||
.quiet()
|
.quiet()
|
||||||
.nothrow()
|
.nothrow()
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export const EditTool = Tool.define("edit", {
|
|||||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||||
const suffix =
|
const suffix =
|
||||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||||
output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -37,7 +37,15 @@ export const GrepTool = Tool.define("grep", {
|
|||||||
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
||||||
|
|
||||||
const rgPath = await Ripgrep.filepath()
|
const rgPath = await Ripgrep.filepath()
|
||||||
const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern]
|
const args = [
|
||||||
|
"-nH",
|
||||||
|
"--hidden",
|
||||||
|
"--follow",
|
||||||
|
"--no-messages",
|
||||||
|
"--field-match-separator=|",
|
||||||
|
"--regexp",
|
||||||
|
params.pattern,
|
||||||
|
]
|
||||||
if (params.include) {
|
if (params.include) {
|
||||||
args.push("--glob", params.include)
|
args.push("--glob", params.include)
|
||||||
}
|
}
|
||||||
@@ -52,7 +60,10 @@ export const GrepTool = Tool.define("grep", {
|
|||||||
const errorOutput = await new Response(proc.stderr).text()
|
const errorOutput = await new Response(proc.stderr).text()
|
||||||
const exitCode = await proc.exited
|
const exitCode = await proc.exited
|
||||||
|
|
||||||
if (exitCode === 1) {
|
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
||||||
|
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
||||||
|
// Only fail if exit code is 2 AND no output was produced
|
||||||
|
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
||||||
return {
|
return {
|
||||||
title: params.pattern,
|
title: params.pattern,
|
||||||
metadata: { matches: 0, truncated: false },
|
metadata: { matches: 0, truncated: false },
|
||||||
@@ -60,10 +71,12 @@ export const GrepTool = Tool.define("grep", {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0 && exitCode !== 2) {
|
||||||
throw new Error(`ripgrep failed: ${errorOutput}`)
|
throw new Error(`ripgrep failed: ${errorOutput}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasErrors = exitCode === 2
|
||||||
|
|
||||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||||
const lines = output.trim().split(/\r?\n/)
|
const lines = output.trim().split(/\r?\n/)
|
||||||
const matches = []
|
const matches = []
|
||||||
@@ -124,6 +137,11 @@ export const GrepTool = Tool.define("grep", {
|
|||||||
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
outputLines.push("")
|
||||||
|
outputLines.push("(Some paths were inaccessible and skipped)")
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: params.pattern,
|
title: params.pattern,
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const WriteTool = Tool.define("write", {
|
|||||||
const suffix =
|
const suffix =
|
||||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||||
if (file === normalizedFilepath) {
|
if (file === normalizedFilepath) {
|
||||||
output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
|
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { realpathSync } from "fs"
|
import { realpathSync } from "fs"
|
||||||
import { exists } from "fs/promises"
|
|
||||||
import { dirname, join, relative } from "path"
|
import { dirname, join, relative } from "path"
|
||||||
|
|
||||||
export namespace Filesystem {
|
export namespace Filesystem {
|
||||||
|
export const exists = (p: string) =>
|
||||||
|
Bun.file(p)
|
||||||
|
.stat()
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
export const isDir = (p: string) =>
|
||||||
|
Bun.file(p)
|
||||||
|
.stat()
|
||||||
|
.then((s) => s.isDirectory())
|
||||||
|
.catch(() => false)
|
||||||
/**
|
/**
|
||||||
* On Windows, normalize a path to its canonical casing using the filesystem.
|
* On Windows, normalize a path to its canonical casing using the filesystem.
|
||||||
* This is needed because Windows paths are case-insensitive but LSP servers
|
* This is needed because Windows paths are case-insensitive but LSP servers
|
||||||
@@ -31,7 +41,7 @@ export namespace Filesystem {
|
|||||||
const result = []
|
const result = []
|
||||||
while (true) {
|
while (true) {
|
||||||
const search = join(current, target)
|
const search = join(current, target)
|
||||||
if (await exists(search).catch(() => false)) result.push(search)
|
if (await exists(search)) result.push(search)
|
||||||
if (stop === current) break
|
if (stop === current) break
|
||||||
const parent = dirname(current)
|
const parent = dirname(current)
|
||||||
if (parent === current) break
|
if (parent === current) break
|
||||||
@@ -46,7 +56,7 @@ export namespace Filesystem {
|
|||||||
while (true) {
|
while (true) {
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const search = join(current, target)
|
const search = join(current, target)
|
||||||
if (await exists(search).catch(() => false)) yield search
|
if (await exists(search)) yield search
|
||||||
}
|
}
|
||||||
if (stop === current) break
|
if (stop === current) break
|
||||||
const parent = dirname(current)
|
const parent = dirname(current)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function formatDuration(secs: number) {
|
||||||
|
if (secs <= 0) return ""
|
||||||
|
if (secs < 60) return `${secs}s`
|
||||||
|
if (secs < 3600) {
|
||||||
|
const mins = Math.floor(secs / 60)
|
||||||
|
const remaining = secs % 60
|
||||||
|
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
|
||||||
|
}
|
||||||
|
if (secs < 86400) {
|
||||||
|
const hours = Math.floor(secs / 3600)
|
||||||
|
const remaining = Math.floor((secs % 3600) / 60)
|
||||||
|
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
|
||||||
|
}
|
||||||
|
if (secs < 604800) {
|
||||||
|
const days = Math.floor(secs / 86400)
|
||||||
|
return days === 1 ? "~1 day" : `~${days} days`
|
||||||
|
}
|
||||||
|
const weeks = Math.floor(secs / 604800)
|
||||||
|
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ export namespace Keybind {
|
|||||||
leader: boolean // our custom field
|
leader: boolean // our custom field
|
||||||
}
|
}
|
||||||
|
|
||||||
export function match(a: Info, b: Info): boolean {
|
export function match(a: Info | undefined, b: Info): boolean {
|
||||||
// Normalize super field (undefined and false are equivalent)
|
if (!a) return false
|
||||||
const normalizedA = { ...a, super: a.super ?? false }
|
const normalizedA = { ...a, super: a.super ?? false }
|
||||||
const normalizedB = { ...b, super: b.super ?? false }
|
const normalizedB = { ...b, super: b.super ?? false }
|
||||||
return isDeepEqual(normalizedA, normalizedB)
|
return isDeepEqual(normalizedA, normalizedB)
|
||||||
@@ -32,7 +32,8 @@ export namespace Keybind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toString(info: Info): string {
|
export function toString(info: Info | undefined): string {
|
||||||
|
if (!info) return ""
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
if (info.ctrl) parts.push("ctrl")
|
if (info.ctrl) parts.push("ctrl")
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
Content
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
description: "This is a description wrapped in quotes"
|
||||||
|
# field: this is a commented out field that should be ignored
|
||||||
|
occupation: This man has the following occupation: Software Engineer
|
||||||
|
title: 'Hello World'
|
||||||
|
name: John "Doe"
|
||||||
|
|
||||||
|
family: He has no 'family'
|
||||||
|
summary: >
|
||||||
|
This is a summary
|
||||||
|
url: https://example.com:8080/path?query=value
|
||||||
|
time: The time is 12:30:00 PM
|
||||||
|
nested: First: Second: Third: Fourth
|
||||||
|
quoted_colon: "Already quoted: no change needed"
|
||||||
|
single_quoted_colon: 'Single quoted: also fine'
|
||||||
|
mixed: He said "hello: world" and then left
|
||||||
|
empty:
|
||||||
|
dollar: Use $' and $& for special patterns
|
||||||
|
---
|
||||||
|
|
||||||
|
Content that should not be parsed:
|
||||||
|
|
||||||
|
fake_field: this is not yaml
|
||||||
|
another: neither is this
|
||||||
|
time: 10:30:00 AM
|
||||||
|
url: https://should-not-be-parsed.com:3000
|
||||||
|
|
||||||
|
The above lines look like YAML but are just content.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Content
|
||||||
@@ -1,89 +1,192 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test, describe } from "bun:test"
|
||||||
import { ConfigMarkdown } from "../../src/config/markdown"
|
import { ConfigMarkdown } from "../../src/config/markdown"
|
||||||
|
|
||||||
const template = `This is a @valid/path/to/a/file and it should also match at
|
describe("ConfigMarkdown: normal template", () => {
|
||||||
the beginning of a line:
|
const template = `This is a @valid/path/to/a/file and it should also match at
|
||||||
|
the beginning of a line:
|
||||||
|
|
||||||
@another-valid/path/to/a/file
|
@another-valid/path/to/a/file
|
||||||
|
|
||||||
but this is not:
|
but this is not:
|
||||||
|
|
||||||
- Adds a "Co-authored-by:" footer which clarifies which AI agent
|
- Adds a "Co-authored-by:" footer which clarifies which AI agent
|
||||||
helped create this commit, using an appropriate \`noreply@...\`
|
helped create this commit, using an appropriate \`noreply@...\`
|
||||||
or \`noreply@anthropic.com\` email address.
|
or \`noreply@anthropic.com\` email address.
|
||||||
|
|
||||||
We also need to deal with files followed by @commas, ones
|
We also need to deal with files followed by @commas, ones
|
||||||
with @file-extensions.md, even @multiple.extensions.bak,
|
with @file-extensions.md, even @multiple.extensions.bak,
|
||||||
hidden directories like @.config/ or files like @.bashrc
|
hidden directories like @.config/ or files like @.bashrc
|
||||||
and ones at the end of a sentence like @foo.md.
|
and ones at the end of a sentence like @foo.md.
|
||||||
|
|
||||||
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
|
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
|
||||||
as well as @~/home-files and @~/paths/under/home.txt.
|
as well as @~/home-files and @~/paths/under/home.txt.
|
||||||
|
|
||||||
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
|
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
|
||||||
|
|
||||||
const matches = ConfigMarkdown.files(template)
|
const matches = ConfigMarkdown.files(template)
|
||||||
|
|
||||||
test("should extract exactly 12 file references", () => {
|
test("should extract exactly 12 file references", () => {
|
||||||
expect(matches.length).toBe(12)
|
expect(matches.length).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract valid/path/to/a/file", () => {
|
||||||
|
expect(matches[0][1]).toBe("valid/path/to/a/file")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract another-valid/path/to/a/file", () => {
|
||||||
|
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract paths ignoring comma after", () => {
|
||||||
|
expect(matches[2][1]).toBe("commas")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract a path with a file extension and comma after", () => {
|
||||||
|
expect(matches[3][1]).toBe("file-extensions.md")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract a path with multiple dots and comma after", () => {
|
||||||
|
expect(matches[4][1]).toBe("multiple.extensions.bak")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract hidden directory", () => {
|
||||||
|
expect(matches[5][1]).toBe(".config/")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract hidden file", () => {
|
||||||
|
expect(matches[6][1]).toBe(".bashrc")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract a file ignoring period at end of sentence", () => {
|
||||||
|
expect(matches[7][1]).toBe("foo.md")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract an absolute path with an extension", () => {
|
||||||
|
expect(matches[8][1]).toBe("/absolute/paths.txt")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract an absolute path without an extension", () => {
|
||||||
|
expect(matches[9][1]).toBe("/without/extensions")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract an absolute path in home directory", () => {
|
||||||
|
expect(matches[10][1]).toBe("~/home-files")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract an absolute path under home directory", () => {
|
||||||
|
expect(matches[11][1]).toBe("~/paths/under/home.txt")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not match when preceded by backtick", () => {
|
||||||
|
const backtickTest = "This `@should/not/match` should be ignored"
|
||||||
|
const backtickMatches = ConfigMarkdown.files(backtickTest)
|
||||||
|
expect(backtickMatches.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not match email addresses", () => {
|
||||||
|
const emailTest = "Contact user@example.com for help"
|
||||||
|
const emailMatches = ConfigMarkdown.files(emailTest)
|
||||||
|
expect(emailMatches.length).toBe(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should extract valid/path/to/a/file", () => {
|
describe("ConfigMarkdown: frontmatter parsing", async () => {
|
||||||
expect(matches[0][1]).toBe("valid/path/to/a/file")
|
const parsed = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/frontmatter.md")
|
||||||
|
|
||||||
|
test("should parse without throwing", () => {
|
||||||
|
expect(parsed).toBeDefined()
|
||||||
|
expect(parsed.data).toBeDefined()
|
||||||
|
expect(parsed.content).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract description field", () => {
|
||||||
|
expect(parsed.data.description).toBe("This is a description wrapped in quotes")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract occupation field with colon in value", () => {
|
||||||
|
expect(parsed.data.occupation).toBe("This man has the following occupation: Software Engineer\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract title field with single quotes", () => {
|
||||||
|
expect(parsed.data.title).toBe("Hello World")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract name field with embedded quotes", () => {
|
||||||
|
expect(parsed.data.name).toBe('John "Doe"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract family field with embedded single quotes", () => {
|
||||||
|
expect(parsed.data.family).toBe("He has no 'family'")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract multiline summary field", () => {
|
||||||
|
expect(parsed.data.summary).toBe("This is a summary\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not include commented fields in data", () => {
|
||||||
|
expect(parsed.data.field).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract URL with port", () => {
|
||||||
|
expect(parsed.data.url).toBe("https://example.com:8080/path?query=value\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract time with colons", () => {
|
||||||
|
expect(parsed.data.time).toBe("The time is 12:30:00 PM\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract value with multiple colons", () => {
|
||||||
|
expect(parsed.data.nested).toBe("First: Second: Third: Fourth\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should preserve already double-quoted values with colons", () => {
|
||||||
|
expect(parsed.data.quoted_colon).toBe("Already quoted: no change needed")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should preserve already single-quoted values with colons", () => {
|
||||||
|
expect(parsed.data.single_quoted_colon).toBe("Single quoted: also fine")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract value with quotes and colons mixed", () => {
|
||||||
|
expect(parsed.data.mixed).toBe('He said "hello: world" and then left\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should handle empty values", () => {
|
||||||
|
expect(parsed.data.empty).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should handle dollar sign replacement patterns literally", () => {
|
||||||
|
expect(parsed.data.dollar).toBe("Use $' and $& for special patterns")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not parse fake yaml from content", () => {
|
||||||
|
expect(parsed.data.fake_field).toBeUndefined()
|
||||||
|
expect(parsed.data.another).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should extract content after frontmatter without modification", () => {
|
||||||
|
expect(parsed.content).toContain("Content that should not be parsed:")
|
||||||
|
expect(parsed.content).toContain("fake_field: this is not yaml")
|
||||||
|
expect(parsed.content).toContain("url: https://should-not-be-parsed.com:3000")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should extract another-valid/path/to/a/file", () => {
|
describe("ConfigMarkdown: frontmatter parsing w/ empty frontmatter", async () => {
|
||||||
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
|
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/empty-frontmatter.md")
|
||||||
|
|
||||||
|
test("should parse without throwing", () => {
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result.data).toEqual({})
|
||||||
|
expect(result.content.trim()).toBe("Content")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should extract paths ignoring comma after", () => {
|
describe("ConfigMarkdown: frontmatter parsing w/ no frontmatter", async () => {
|
||||||
expect(matches[2][1]).toBe("commas")
|
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/no-frontmatter.md")
|
||||||
})
|
|
||||||
|
|
||||||
test("should extract a path with a file extension and comma after", () => {
|
test("should parse without throwing", () => {
|
||||||
expect(matches[3][1]).toBe("file-extensions.md")
|
expect(result).toBeDefined()
|
||||||
})
|
expect(result.data).toEqual({})
|
||||||
|
expect(result.content.trim()).toBe("Content")
|
||||||
test("should extract a path with multiple dots and comma after", () => {
|
})
|
||||||
expect(matches[4][1]).toBe("multiple.extensions.bak")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should extract hidden directory", () => {
|
|
||||||
expect(matches[5][1]).toBe(".config/")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should extract hidden file", () => {
|
|
||||||
expect(matches[6][1]).toBe(".bashrc")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should extract a file ignoring period at end of sentence", () => {
|
|
||||||
expect(matches[7][1]).toBe("foo.md")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should extract an absolute path with an extension", () => {
|
|
||||||
expect(matches[8][1]).toBe("/absolute/paths.txt")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should extract an absolute path without an extension", () => {
|
|
||||||
expect(matches[9][1]).toBe("/without/extensions")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should extract an absolute path in home directory", () => {
|
|
||||||
expect(matches[10][1]).toBe("~/home-files")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should extract an absolute path under home directory", () => {
|
|
||||||
expect(matches[11][1]).toBe("~/paths/under/home.txt")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should not match when preceded by backtick", () => {
|
|
||||||
const backtickTest = "This `@should/not/match` should be ignored"
|
|
||||||
const backtickMatches = ConfigMarkdown.files(backtickTest)
|
|
||||||
expect(backtickMatches.length).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should not match email addresses", () => {
|
|
||||||
const emailTest = "Contact user@example.com for help"
|
|
||||||
const emailMatches = ConfigMarkdown.files(emailTest)
|
|
||||||
expect(emailMatches.length).toBe(0)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect, mock } from "bun:test"
|
import { test, expect, mock } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { unlink } from "fs/promises"
|
||||||
|
|
||||||
// === Mocks ===
|
// === Mocks ===
|
||||||
// These mocks are required because Provider.list() triggers:
|
// These mocks are required because Provider.list() triggers:
|
||||||
@@ -118,29 +119,52 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const authPath = path.join(Global.Path.data, "auth.json")
|
const authPath = path.join(Global.Path.data, "auth.json")
|
||||||
await Bun.write(
|
|
||||||
authPath,
|
|
||||||
JSON.stringify({
|
|
||||||
"amazon-bedrock": {
|
|
||||||
type: "api",
|
|
||||||
key: "test-bearer-token",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
await Instance.provide({
|
// Save original auth.json if it exists
|
||||||
directory: tmp.path,
|
let originalAuth: string | undefined
|
||||||
init: async () => {
|
try {
|
||||||
Env.set("AWS_PROFILE", "")
|
originalAuth = await Bun.file(authPath).text()
|
||||||
Env.set("AWS_ACCESS_KEY_ID", "")
|
} catch {
|
||||||
Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
|
// File doesn't exist, that's fine
|
||||||
},
|
}
|
||||||
fn: async () => {
|
|
||||||
const providers = await Provider.list()
|
try {
|
||||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
// Write test auth.json
|
||||||
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
await Bun.write(
|
||||||
},
|
authPath,
|
||||||
})
|
JSON.stringify({
|
||||||
|
"amazon-bedrock": {
|
||||||
|
type: "api",
|
||||||
|
key: "test-bearer-token",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
init: async () => {
|
||||||
|
Env.set("AWS_PROFILE", "")
|
||||||
|
Env.set("AWS_ACCESS_KEY_ID", "")
|
||||||
|
Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||||
|
},
|
||||||
|
fn: async () => {
|
||||||
|
const providers = await Provider.list()
|
||||||
|
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||||
|
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// Restore original or delete
|
||||||
|
if (originalAuth !== undefined) {
|
||||||
|
await Bun.write(authPath, originalAuth)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await unlink(authPath)
|
||||||
|
} catch {
|
||||||
|
// Ignore errors if file doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async () => {
|
test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async () => {
|
||||||
@@ -208,3 +232,37 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
provider: {
|
||||||
|
"amazon-bedrock": {
|
||||||
|
options: {
|
||||||
|
region: "us-east-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
init: async () => {
|
||||||
|
Env.set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
|
||||||
|
Env.set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
|
||||||
|
Env.set("AWS_PROFILE", "")
|
||||||
|
Env.set("AWS_ACCESS_KEY_ID", "")
|
||||||
|
},
|
||||||
|
fn: async () => {
|
||||||
|
const providers = await Provider.list()
|
||||||
|
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||||
|
expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -39,22 +39,34 @@ describe("ProviderTransform.options - setCacheKey", () => {
|
|||||||
} as any
|
} as any
|
||||||
|
|
||||||
test("should set promptCacheKey when providerOptions.setCacheKey is true", () => {
|
test("should set promptCacheKey when providerOptions.setCacheKey is true", () => {
|
||||||
const result = ProviderTransform.options(mockModel, sessionID, { setCacheKey: true })
|
const result = ProviderTransform.options({
|
||||||
|
model: mockModel,
|
||||||
|
sessionID,
|
||||||
|
providerOptions: { setCacheKey: true },
|
||||||
|
})
|
||||||
expect(result.promptCacheKey).toBe(sessionID)
|
expect(result.promptCacheKey).toBe(sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should not set promptCacheKey when providerOptions.setCacheKey is false", () => {
|
test("should not set promptCacheKey when providerOptions.setCacheKey is false", () => {
|
||||||
const result = ProviderTransform.options(mockModel, sessionID, { setCacheKey: false })
|
const result = ProviderTransform.options({
|
||||||
|
model: mockModel,
|
||||||
|
sessionID,
|
||||||
|
providerOptions: { setCacheKey: false },
|
||||||
|
})
|
||||||
expect(result.promptCacheKey).toBeUndefined()
|
expect(result.promptCacheKey).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should not set promptCacheKey when providerOptions is undefined", () => {
|
test("should not set promptCacheKey when providerOptions is undefined", () => {
|
||||||
const result = ProviderTransform.options(mockModel, sessionID, undefined)
|
const result = ProviderTransform.options({
|
||||||
|
model: mockModel,
|
||||||
|
sessionID,
|
||||||
|
providerOptions: undefined,
|
||||||
|
})
|
||||||
expect(result.promptCacheKey).toBeUndefined()
|
expect(result.promptCacheKey).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should not set promptCacheKey when providerOptions does not have setCacheKey", () => {
|
test("should not set promptCacheKey when providerOptions does not have setCacheKey", () => {
|
||||||
const result = ProviderTransform.options(mockModel, sessionID, {})
|
const result = ProviderTransform.options({ model: mockModel, sessionID, providerOptions: {} })
|
||||||
expect(result.promptCacheKey).toBeUndefined()
|
expect(result.promptCacheKey).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,9 +80,27 @@ describe("ProviderTransform.options - setCacheKey", () => {
|
|||||||
npm: "@ai-sdk/openai",
|
npm: "@ai-sdk/openai",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const result = ProviderTransform.options(openaiModel, sessionID, {})
|
const result = ProviderTransform.options({ model: openaiModel, sessionID, providerOptions: {} })
|
||||||
expect(result.promptCacheKey).toBe(sessionID)
|
expect(result.promptCacheKey).toBe(sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should set store=false for openai provider", () => {
|
||||||
|
const openaiModel = {
|
||||||
|
...mockModel,
|
||||||
|
providerID: "openai",
|
||||||
|
api: {
|
||||||
|
id: "gpt-4",
|
||||||
|
url: "https://api.openai.com",
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = ProviderTransform.options({
|
||||||
|
model: openaiModel,
|
||||||
|
sessionID,
|
||||||
|
providerOptions: {},
|
||||||
|
})
|
||||||
|
expect(result.store).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("ProviderTransform.maxOutputTokens", () => {
|
describe("ProviderTransform.maxOutputTokens", () => {
|
||||||
@@ -208,40 +238,44 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
|
|||||||
},
|
},
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, {
|
const result = ProviderTransform.message(
|
||||||
id: "deepseek/deepseek-chat",
|
msgs,
|
||||||
providerID: "deepseek",
|
{
|
||||||
api: {
|
id: "deepseek/deepseek-chat",
|
||||||
id: "deepseek-chat",
|
providerID: "deepseek",
|
||||||
url: "https://api.deepseek.com",
|
api: {
|
||||||
npm: "@ai-sdk/openai-compatible",
|
id: "deepseek-chat",
|
||||||
},
|
url: "https://api.deepseek.com",
|
||||||
name: "DeepSeek Chat",
|
npm: "@ai-sdk/openai-compatible",
|
||||||
capabilities: {
|
|
||||||
temperature: true,
|
|
||||||
reasoning: true,
|
|
||||||
attachment: false,
|
|
||||||
toolcall: true,
|
|
||||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
||||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
||||||
interleaved: {
|
|
||||||
field: "reasoning_content",
|
|
||||||
},
|
},
|
||||||
|
name: "DeepSeek Chat",
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: false,
|
||||||
|
toolcall: true,
|
||||||
|
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
interleaved: {
|
||||||
|
field: "reasoning_content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0.001,
|
||||||
|
output: 0.002,
|
||||||
|
cache: { read: 0.0001, write: 0.0002 },
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128000,
|
||||||
|
output: 8192,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
release_date: "2023-04-01",
|
||||||
},
|
},
|
||||||
cost: {
|
{},
|
||||||
input: 0.001,
|
)
|
||||||
output: 0.002,
|
|
||||||
cache: { read: 0.0001, write: 0.0002 },
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
context: 128000,
|
|
||||||
output: 8192,
|
|
||||||
},
|
|
||||||
status: "active",
|
|
||||||
options: {},
|
|
||||||
headers: {},
|
|
||||||
release_date: "2023-04-01",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].content).toEqual([
|
expect(result[0].content).toEqual([
|
||||||
@@ -266,38 +300,42 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
|
|||||||
},
|
},
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, {
|
const result = ProviderTransform.message(
|
||||||
id: "openai/gpt-4",
|
msgs,
|
||||||
providerID: "openai",
|
{
|
||||||
api: {
|
id: "openai/gpt-4",
|
||||||
id: "gpt-4",
|
providerID: "openai",
|
||||||
url: "https://api.openai.com",
|
api: {
|
||||||
npm: "@ai-sdk/openai",
|
id: "gpt-4",
|
||||||
|
url: "https://api.openai.com",
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
},
|
||||||
|
name: "GPT-4",
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: false,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
||||||
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
interleaved: false,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0.03,
|
||||||
|
output: 0.06,
|
||||||
|
cache: { read: 0.001, write: 0.002 },
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128000,
|
||||||
|
output: 4096,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
release_date: "2023-04-01",
|
||||||
},
|
},
|
||||||
name: "GPT-4",
|
{},
|
||||||
capabilities: {
|
)
|
||||||
temperature: true,
|
|
||||||
reasoning: false,
|
|
||||||
attachment: true,
|
|
||||||
toolcall: true,
|
|
||||||
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
|
||||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
||||||
interleaved: false,
|
|
||||||
},
|
|
||||||
cost: {
|
|
||||||
input: 0.03,
|
|
||||||
output: 0.06,
|
|
||||||
cache: { read: 0.001, write: 0.002 },
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
context: 128000,
|
|
||||||
output: 4096,
|
|
||||||
},
|
|
||||||
status: "active",
|
|
||||||
options: {},
|
|
||||||
headers: {},
|
|
||||||
release_date: "2023-04-01",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result[0].content).toEqual([
|
expect(result[0].content).toEqual([
|
||||||
{ type: "reasoning", text: "Should not be processed" },
|
{ type: "reasoning", text: "Should not be processed" },
|
||||||
@@ -351,7 +389,7 @@ describe("ProviderTransform.message - empty image handling", () => {
|
|||||||
},
|
},
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, mockModel)
|
const result = ProviderTransform.message(msgs, mockModel, {})
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].content).toHaveLength(2)
|
expect(result[0].content).toHaveLength(2)
|
||||||
@@ -375,7 +413,7 @@ describe("ProviderTransform.message - empty image handling", () => {
|
|||||||
},
|
},
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, mockModel)
|
const result = ProviderTransform.message(msgs, mockModel, {})
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].content).toHaveLength(2)
|
expect(result[0].content).toHaveLength(2)
|
||||||
@@ -397,7 +435,7 @@ describe("ProviderTransform.message - empty image handling", () => {
|
|||||||
},
|
},
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, mockModel)
|
const result = ProviderTransform.message(msgs, mockModel, {})
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].content).toHaveLength(3)
|
expect(result[0].content).toHaveLength(3)
|
||||||
@@ -450,7 +488,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
|||||||
{ role: "user", content: "World" },
|
{ role: "user", content: "World" },
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
const result = ProviderTransform.message(msgs, anthropicModel, {})
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
expect(result).toHaveLength(2)
|
||||||
expect(result[0].content).toBe("Hello")
|
expect(result[0].content).toBe("Hello")
|
||||||
@@ -469,7 +507,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
|||||||
},
|
},
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
const result = ProviderTransform.message(msgs, anthropicModel, {})
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].content).toHaveLength(1)
|
expect(result[0].content).toHaveLength(1)
|
||||||
@@ -488,7 +526,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
|||||||
},
|
},
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
const result = ProviderTransform.message(msgs, anthropicModel, {})
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].content).toHaveLength(1)
|
expect(result[0].content).toHaveLength(1)
|
||||||
@@ -508,7 +546,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
|||||||
{ role: "user", content: "World" },
|
{ role: "user", content: "World" },
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
const result = ProviderTransform.message(msgs, anthropicModel, {})
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
expect(result).toHaveLength(2)
|
||||||
expect(result[0].content).toBe("Hello")
|
expect(result[0].content).toBe("Hello")
|
||||||
@@ -526,7 +564,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
|||||||
},
|
},
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
const result = ProviderTransform.message(msgs, anthropicModel, {})
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].content).toHaveLength(1)
|
expect(result[0].content).toHaveLength(1)
|
||||||
@@ -550,7 +588,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
|||||||
},
|
},
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
const result = ProviderTransform.message(msgs, anthropicModel, {})
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].content).toHaveLength(2)
|
expect(result[0].content).toHaveLength(2)
|
||||||
@@ -577,7 +615,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
|||||||
},
|
},
|
||||||
] as any[]
|
] as any[]
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, openaiModel)
|
const result = ProviderTransform.message(msgs, openaiModel, {})
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
expect(result).toHaveLength(2)
|
||||||
expect(result[0].content).toBe("")
|
expect(result[0].content).toBe("")
|
||||||
@@ -585,6 +623,223 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("ProviderTransform.message - strip openai metadata when store=false", () => {
|
||||||
|
const openaiModel = {
|
||||||
|
id: "openai/gpt-5",
|
||||||
|
providerID: "openai",
|
||||||
|
api: {
|
||||||
|
id: "gpt-5",
|
||||||
|
url: "https://api.openai.com",
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
},
|
||||||
|
name: "GPT-5",
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
||||||
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
interleaved: false,
|
||||||
|
},
|
||||||
|
cost: { input: 0.03, output: 0.06, cache: { read: 0.001, write: 0.002 } },
|
||||||
|
limit: { context: 128000, output: 4096 },
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
} as any
|
||||||
|
|
||||||
|
test("strips itemId and reasoningEncryptedContent when store=false", () => {
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "thinking...",
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
itemId: "rs_123",
|
||||||
|
reasoningEncryptedContent: "encrypted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Hello",
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
itemId: "msg_456",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[]
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
|
||||||
|
expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBeUndefined()
|
||||||
|
expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips itemId and reasoningEncryptedContent when store=false even when not openai", () => {
|
||||||
|
const zenModel = {
|
||||||
|
...openaiModel,
|
||||||
|
providerID: "zen",
|
||||||
|
}
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "thinking...",
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
itemId: "rs_123",
|
||||||
|
reasoningEncryptedContent: "encrypted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Hello",
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
itemId: "msg_456",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[]
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
|
||||||
|
expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBeUndefined()
|
||||||
|
expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves other openai options when stripping itemId", () => {
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Hello",
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
itemId: "msg_123",
|
||||||
|
otherOption: "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[]
|
||||||
|
|
||||||
|
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
|
||||||
|
expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips metadata for openai package even when store is true", () => {
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Hello",
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
itemId: "msg_123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
// openai package always strips itemId regardless of store value
|
||||||
|
const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[]
|
||||||
|
|
||||||
|
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips metadata for non-openai packages when store is false", () => {
|
||||||
|
const anthropicModel = {
|
||||||
|
...openaiModel,
|
||||||
|
providerID: "anthropic",
|
||||||
|
api: {
|
||||||
|
id: "claude-3",
|
||||||
|
url: "https://api.anthropic.com",
|
||||||
|
npm: "@ai-sdk/anthropic",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Hello",
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
itemId: "msg_123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
// store=false triggers stripping even for non-openai packages
|
||||||
|
const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[]
|
||||||
|
|
||||||
|
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not strip metadata for non-openai packages when store is not false", () => {
|
||||||
|
const anthropicModel = {
|
||||||
|
...openaiModel,
|
||||||
|
providerID: "anthropic",
|
||||||
|
api: {
|
||||||
|
id: "claude-3",
|
||||||
|
url: "https://api.anthropic.com",
|
||||||
|
npm: "@ai-sdk/anthropic",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Hello",
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
itemId: "msg_123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
|
||||||
|
|
||||||
|
expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("ProviderTransform.variants", () => {
|
describe("ProviderTransform.variants", () => {
|
||||||
const createMockModel = (overrides: Partial<any> = {}): any => ({
|
const createMockModel = (overrides: Partial<any> = {}): any => ({
|
||||||
id: "test/test-model",
|
id: "test/test-model",
|
||||||
|
|||||||
@@ -10,13 +10,19 @@ import type { Provider } from "../../src/provider/provider"
|
|||||||
|
|
||||||
Log.init({ print: false })
|
Log.init({ print: false })
|
||||||
|
|
||||||
function createModel(opts: { context: number; output: number; cost?: Provider.Model["cost"] }): Provider.Model {
|
function createModel(opts: {
|
||||||
|
context: number
|
||||||
|
output: number
|
||||||
|
input?: number
|
||||||
|
cost?: Provider.Model["cost"]
|
||||||
|
}): Provider.Model {
|
||||||
return {
|
return {
|
||||||
id: "test-model",
|
id: "test-model",
|
||||||
providerID: "test",
|
providerID: "test",
|
||||||
name: "Test",
|
name: "Test",
|
||||||
limit: {
|
limit: {
|
||||||
context: opts.context,
|
context: opts.context,
|
||||||
|
input: opts.input,
|
||||||
output: opts.output,
|
output: opts.output,
|
||||||
},
|
},
|
||||||
cost: opts.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
cost: opts.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||||
@@ -70,6 +76,42 @@ describe("session.compaction.isOverflow", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("respects input limit for input caps", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
|
||||||
|
const tokens = { input: 271_000, output: 1_000, reasoning: 0, cache: { read: 2_000, write: 0 } }
|
||||||
|
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when input/output are within input caps", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
|
||||||
|
const tokens = { input: 200_000, output: 20_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
|
||||||
|
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when output within limit with input caps", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const model = createModel({ context: 200_000, input: 120_000, output: 10_000 })
|
||||||
|
const tokens = { input: 50_000, output: 9_999, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||||
|
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("returns false when model context limit is 0", async () => {
|
test("returns false when model context limit is 0", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { mkdtemp, mkdir, rm } from "node:fs/promises"
|
||||||
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
|
||||||
|
describe("util.filesystem", () => {
|
||||||
|
test("exists() is true for files and directories", async () => {
|
||||||
|
const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
|
||||||
|
const dir = path.join(tmp, "dir")
|
||||||
|
const file = path.join(tmp, "file.txt")
|
||||||
|
const missing = path.join(tmp, "missing")
|
||||||
|
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
await Bun.write(file, "hello")
|
||||||
|
|
||||||
|
const cases = await Promise.all([Filesystem.exists(dir), Filesystem.exists(file), Filesystem.exists(missing)])
|
||||||
|
|
||||||
|
expect(cases).toEqual([true, true, false])
|
||||||
|
|
||||||
|
await rm(tmp, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("isDir() is true only for directories", async () => {
|
||||||
|
const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
|
||||||
|
const dir = path.join(tmp, "dir")
|
||||||
|
const file = path.join(tmp, "file.txt")
|
||||||
|
const missing = path.join(tmp, "missing")
|
||||||
|
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
await Bun.write(file, "hello")
|
||||||
|
|
||||||
|
const cases = await Promise.all([Filesystem.isDir(dir), Filesystem.isDir(file), Filesystem.isDir(missing)])
|
||||||
|
|
||||||
|
expect(cases).toEqual([true, false, false])
|
||||||
|
|
||||||
|
await rm(tmp, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { formatDuration } from "../../src/util/format"
|
||||||
|
|
||||||
|
describe("util.format", () => {
|
||||||
|
describe("formatDuration", () => {
|
||||||
|
test("returns empty string for zero or negative values", () => {
|
||||||
|
expect(formatDuration(0)).toBe("")
|
||||||
|
expect(formatDuration(-1)).toBe("")
|
||||||
|
expect(formatDuration(-100)).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("formats seconds under a minute", () => {
|
||||||
|
expect(formatDuration(1)).toBe("1s")
|
||||||
|
expect(formatDuration(30)).toBe("30s")
|
||||||
|
expect(formatDuration(59)).toBe("59s")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("formats minutes under an hour", () => {
|
||||||
|
expect(formatDuration(60)).toBe("1m")
|
||||||
|
expect(formatDuration(61)).toBe("1m 1s")
|
||||||
|
expect(formatDuration(90)).toBe("1m 30s")
|
||||||
|
expect(formatDuration(120)).toBe("2m")
|
||||||
|
expect(formatDuration(330)).toBe("5m 30s")
|
||||||
|
expect(formatDuration(3599)).toBe("59m 59s")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("formats hours under a day", () => {
|
||||||
|
expect(formatDuration(3600)).toBe("1h")
|
||||||
|
expect(formatDuration(3660)).toBe("1h 1m")
|
||||||
|
expect(formatDuration(7200)).toBe("2h")
|
||||||
|
expect(formatDuration(8100)).toBe("2h 15m")
|
||||||
|
expect(formatDuration(86399)).toBe("23h 59m")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("formats days under a week", () => {
|
||||||
|
expect(formatDuration(86400)).toBe("~1 day")
|
||||||
|
expect(formatDuration(172800)).toBe("~2 days")
|
||||||
|
expect(formatDuration(259200)).toBe("~3 days")
|
||||||
|
expect(formatDuration(604799)).toBe("~6 days")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("formats weeks", () => {
|
||||||
|
expect(formatDuration(604800)).toBe("~1 week")
|
||||||
|
expect(formatDuration(1209600)).toBe("~2 weeks")
|
||||||
|
expect(formatDuration(1609200)).toBe("~2 weeks")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles boundary values correctly", () => {
|
||||||
|
expect(formatDuration(59)).toBe("59s")
|
||||||
|
expect(formatDuration(60)).toBe("1m")
|
||||||
|
expect(formatDuration(3599)).toBe("59m 59s")
|
||||||
|
expect(formatDuration(3600)).toBe("1h")
|
||||||
|
expect(formatDuration(86399)).toBe("23h 59m")
|
||||||
|
expect(formatDuration(86400)).toBe("~1 day")
|
||||||
|
expect(formatDuration(604799)).toBe("~6 days")
|
||||||
|
expect(formatDuration(604800)).toBe("~1 week")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -966,6 +966,22 @@ export type KeybindsConfig = {
|
|||||||
* Rename session
|
* Rename session
|
||||||
*/
|
*/
|
||||||
session_rename?: string
|
session_rename?: string
|
||||||
|
/**
|
||||||
|
* Delete session
|
||||||
|
*/
|
||||||
|
session_delete?: string
|
||||||
|
/**
|
||||||
|
* Delete stash entry
|
||||||
|
*/
|
||||||
|
stash_delete?: string
|
||||||
|
/**
|
||||||
|
* Open provider list from model dialog
|
||||||
|
*/
|
||||||
|
model_provider_list?: string
|
||||||
|
/**
|
||||||
|
* Toggle model favorite status
|
||||||
|
*/
|
||||||
|
model_favorite_toggle?: string
|
||||||
/**
|
/**
|
||||||
* Share current session
|
* Share current session
|
||||||
*/
|
*/
|
||||||
@@ -1410,6 +1426,7 @@ export type ProviderConfig = {
|
|||||||
}
|
}
|
||||||
limit?: {
|
limit?: {
|
||||||
context: number
|
context: number
|
||||||
|
input?: number
|
||||||
output: number
|
output: number
|
||||||
}
|
}
|
||||||
modalities?: {
|
modalities?: {
|
||||||
@@ -1482,7 +1499,7 @@ export type McpLocalConfig = {
|
|||||||
*/
|
*/
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
/**
|
/**
|
||||||
* Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.
|
* Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.
|
||||||
*/
|
*/
|
||||||
timeout?: number
|
timeout?: number
|
||||||
}
|
}
|
||||||
@@ -1526,7 +1543,7 @@ export type McpRemoteConfig = {
|
|||||||
*/
|
*/
|
||||||
oauth?: McpOAuthConfig | false
|
oauth?: McpOAuthConfig | false
|
||||||
/**
|
/**
|
||||||
* Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.
|
* Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.
|
||||||
*/
|
*/
|
||||||
timeout?: number
|
timeout?: number
|
||||||
}
|
}
|
||||||
@@ -1903,6 +1920,7 @@ export type Model = {
|
|||||||
}
|
}
|
||||||
limit: {
|
limit: {
|
||||||
context: number
|
context: number
|
||||||
|
input?: number
|
||||||
output: number
|
output: number
|
||||||
}
|
}
|
||||||
status: "alpha" | "beta" | "deprecated" | "active"
|
status: "alpha" | "beta" | "deprecated" | "active"
|
||||||
@@ -3808,6 +3826,7 @@ export type ProviderListResponses = {
|
|||||||
}
|
}
|
||||||
limit: {
|
limit: {
|
||||||
context: number
|
context: number
|
||||||
|
input?: number
|
||||||
output: number
|
output: number
|
||||||
}
|
}
|
||||||
modalities?: {
|
modalities?: {
|
||||||
|
|||||||
@@ -3572,6 +3572,9 @@
|
|||||||
"context": {
|
"context": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
@@ -8168,7 +8171,27 @@
|
|||||||
},
|
},
|
||||||
"session_rename": {
|
"session_rename": {
|
||||||
"description": "Rename session",
|
"description": "Rename session",
|
||||||
"default": "none",
|
"default": "ctrl+r",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"session_delete": {
|
||||||
|
"description": "Delete session",
|
||||||
|
"default": "ctrl+d",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"stash_delete": {
|
||||||
|
"description": "Delete stash entry",
|
||||||
|
"default": "ctrl+d",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"model_provider_list": {
|
||||||
|
"description": "Open provider list from model dialog",
|
||||||
|
"default": "ctrl+a",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"model_favorite_toggle": {
|
||||||
|
"description": "Toggle model favorite status",
|
||||||
|
"default": "ctrl+f",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"session_share": {
|
"session_share": {
|
||||||
@@ -8848,6 +8871,9 @@
|
|||||||
"context": {
|
"context": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
@@ -9007,7 +9033,7 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"timeout": {
|
"timeout": {
|
||||||
"description": "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.",
|
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"exclusiveMinimum": 0,
|
"exclusiveMinimum": 0,
|
||||||
"maximum": 9007199254740991
|
"maximum": 9007199254740991
|
||||||
@@ -9073,7 +9099,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"timeout": {
|
"timeout": {
|
||||||
"description": "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.",
|
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"exclusiveMinimum": 0,
|
"exclusiveMinimum": 0,
|
||||||
"maximum": 9007199254740991
|
"maximum": 9007199254740991
|
||||||
@@ -9978,6 +10004,9 @@
|
|||||||
"context": {
|
"context": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user