mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-25 10:14:26 +00:00
Compare commits
62 Commits
fix/beta-s
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d848c9b6a3 | ||
|
|
561f9f5f05 | ||
|
|
3c6c74457d | ||
|
|
fc6e7934bd | ||
|
|
d7500b25b8 | ||
|
|
5d5f2cfee6 | ||
|
|
1172ebe697 | ||
|
|
d00d98d56a | ||
|
|
6fc5506293 | ||
|
|
76b60f3779 | ||
|
|
6af7ddf03b | ||
|
|
0b3fb5d460 | ||
|
|
a487f11a30 | ||
|
|
637059a515 | ||
|
|
fa559b0385 | ||
|
|
814c1d398c | ||
|
|
da40ab7b3d | ||
|
|
e718263778 | ||
|
|
3af12c53c4 | ||
|
|
29ddd55088 | ||
|
|
2c00eb60bd | ||
|
|
2a87860c06 | ||
|
|
68cf011fd3 | ||
|
|
f8cfb697bd | ||
|
|
c6d8e7624d | ||
|
|
0d0d0578eb | ||
|
|
cc02476ea5 | ||
|
|
5190589632 | ||
|
|
c92913e962 | ||
|
|
082f0cc127 | ||
|
|
2cee947671 | ||
|
|
e27d3d5d40 | ||
|
|
32417774c4 | ||
|
|
36197f5ff8 | ||
|
|
3d379c20c4 | ||
|
|
06f25c78f6 | ||
|
|
1a0639e5b8 | ||
|
|
1af3e9e557 | ||
|
|
a292eddeb5 | ||
|
|
79254c1020 | ||
|
|
ef7f222d80 | ||
|
|
888b123387 | ||
|
|
13cabae29f | ||
|
|
659068942e | ||
|
|
3201a7d34b | ||
|
|
de796d9a00 | ||
|
|
a592bd9684 | ||
|
|
744059a00f | ||
|
|
fb6d201ee0 | ||
|
|
cda2af2589 | ||
|
|
eda71373b0 | ||
|
|
cf5cfb48cd | ||
|
|
ae190038f8 | ||
|
|
0269f39a17 | ||
|
|
0a91196919 | ||
|
|
284251ad66 | ||
|
|
34495a70d5 | ||
|
|
ad5f0816a3 | ||
|
|
24c63914bf | ||
|
|
8f2d8dd47a | ||
|
|
3b5b21a91e | ||
|
|
8e96447960 |
69
.github/actions/setup-bun/action.yml
vendored
69
.github/actions/setup-bun/action.yml
vendored
@@ -1,5 +1,10 @@
|
||||
name: "Setup Bun"
|
||||
description: "Setup Bun with caching and install dependencies"
|
||||
inputs:
|
||||
cross-compile:
|
||||
description: "Pre-cache canary cross-compile binaries for all targets"
|
||||
required: false
|
||||
default: "false"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -11,10 +16,72 @@ runs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Get baseline download URL
|
||||
id: bun-url
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_ARCH" = "X64" ]; then
|
||||
case "$RUNNER_OS" in
|
||||
macOS) OS=darwin ;;
|
||||
Linux) OS=linux ;;
|
||||
Windows) OS=windows ;;
|
||||
esac
|
||||
echo "url=https://github.com/oven-sh/bun/releases/download/canary/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: package.json
|
||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||
|
||||
- name: Pre-cache canary cross-compile binaries
|
||||
if: inputs.cross-compile == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
BUN_VERSION=$(bun --revision)
|
||||
if echo "$BUN_VERSION" | grep -q "canary"; then
|
||||
SEMVER=$(echo "$BUN_VERSION" | sed 's/^\([0-9]*\.[0-9]*\.[0-9]*\).*/\1/')
|
||||
echo "Bun version: $BUN_VERSION (semver: $SEMVER)"
|
||||
CACHE_DIR="$HOME/.bun/install/cache"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
TMP_DIR=$(mktemp -d)
|
||||
for TARGET in linux-aarch64 linux-x64 linux-x64-baseline linux-aarch64-musl linux-x64-musl linux-x64-musl-baseline darwin-aarch64 darwin-x64 windows-x64 windows-x64-baseline; do
|
||||
DEST="$CACHE_DIR/bun-${TARGET}-v${SEMVER}"
|
||||
if [ -f "$DEST" ]; then
|
||||
echo "Already cached: $DEST"
|
||||
continue
|
||||
fi
|
||||
URL="https://github.com/oven-sh/bun/releases/download/canary/bun-${TARGET}.zip"
|
||||
echo "Downloading $TARGET from $URL"
|
||||
if curl -sfL -o "$TMP_DIR/bun.zip" "$URL"; then
|
||||
unzip -qo "$TMP_DIR/bun.zip" -d "$TMP_DIR"
|
||||
if echo "$TARGET" | grep -q "windows"; then
|
||||
BIN_NAME="bun.exe"
|
||||
else
|
||||
BIN_NAME="bun"
|
||||
fi
|
||||
mv "$TMP_DIR/bun-${TARGET}/$BIN_NAME" "$DEST"
|
||||
chmod +x "$DEST"
|
||||
rm -rf "$TMP_DIR/bun-${TARGET}" "$TMP_DIR/bun.zip"
|
||||
echo "Cached: $DEST"
|
||||
# baseline bun resolves "bun-darwin-x64" to the baseline cache key
|
||||
# so copy the modern binary there too
|
||||
if [ "$TARGET" = "darwin-x64" ]; then
|
||||
BASELINE_DEST="$CACHE_DIR/bun-darwin-x64-baseline-v${SEMVER}"
|
||||
if [ ! -f "$BASELINE_DEST" ]; then
|
||||
cp "$DEST" "$BASELINE_DEST"
|
||||
echo "Cached (baseline alias): $BASELINE_DEST"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Skipped: $TARGET (not available)"
|
||||
fi
|
||||
done
|
||||
rm -rf "$TMP_DIR"
|
||||
else
|
||||
echo "Not a canary build ($BUN_VERSION), skipping pre-cache"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
8
.github/workflows/beta.yml
vendored
8
.github/workflows/beta.yml
vendored
@@ -5,10 +5,6 @@ on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
concurrency:
|
||||
group: beta-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -31,7 +27,11 @@ jobs:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Install OpenCode
|
||||
run: bun i -g opencode-ai
|
||||
|
||||
- name: Sync beta branch
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
run: bun script/beta.ts
|
||||
|
||||
9
.github/workflows/compliance-close.yml
vendored
9
.github/workflows/compliance-close.yml
vendored
@@ -65,6 +65,15 @@ jobs:
|
||||
body: closeMessage,
|
||||
});
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
name: 'needs:compliance',
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
if (isPR) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
|
||||
12
.github/workflows/pr-standards.yml
vendored
12
.github/workflows/pr-standards.yml
vendored
@@ -108,11 +108,11 @@ jobs:
|
||||
|
||||
await removeLabel('needs:title');
|
||||
|
||||
// Step 2: Check for linked issue (skip for docs/refactor PRs)
|
||||
const skipIssueCheck = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||
// Step 2: Check for linked issue (skip for docs/refactor/feat PRs)
|
||||
const skipIssueCheck = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||
if (skipIssueCheck) {
|
||||
await removeLabel('needs:issue');
|
||||
console.log('Skipping issue check for docs/refactor PR');
|
||||
console.log('Skipping issue check for docs/refactor/feat PR');
|
||||
return;
|
||||
}
|
||||
const query = `
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
|
||||
const body = pr.body || '';
|
||||
const title = pr.title;
|
||||
const isDocsOrRefactor = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||
const isDocsRefactorOrFeat = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||
|
||||
const issues = [];
|
||||
|
||||
@@ -225,8 +225,8 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
// Check: issue reference (skip for docs/refactor)
|
||||
if (!isDocsOrRefactor && hasIssueSection) {
|
||||
// Check: issue reference (skip for docs/refactor/feat)
|
||||
if (!isDocsRefactorOrFeat && hasIssueSection) {
|
||||
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
|
||||
const issueContent = issueMatch ? issueMatch[1].trim() : '';
|
||||
const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
|
||||
|
||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -77,6 +77,8 @@ jobs:
|
||||
fetch-tags: true
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
cross-compile: "true"
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -88,7 +90,7 @@ jobs:
|
||||
- name: Build
|
||||
id: build
|
||||
run: |
|
||||
./packages/opencode/script/build.ts
|
||||
./packages/opencode/script/build.ts --all
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
|
||||
4
.github/workflows/sign-cli.yml
vendored
4
.github/workflows/sign-cli.yml
vendored
@@ -20,10 +20,12 @@ jobs:
|
||||
fetch-tags: true
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
cross-compile: "true"
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
./packages/opencode/script/build.ts
|
||||
./packages/opencode/script/build.ts --all
|
||||
|
||||
- name: Upload unsigned Windows CLI
|
||||
id: upload_unsigned_windows_cli
|
||||
|
||||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
@@ -8,8 +8,16 @@ on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
unit:
|
||||
name: unit (linux)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
name: unit (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
- name: windows
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
30
bun.lock
30
bun.lock
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -184,7 +184,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -246,7 +246,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -262,7 +262,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -376,7 +376,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -396,7 +396,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -407,7 +407,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -420,7 +420,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -462,7 +462,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -473,7 +473,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
|
||||
export const serverUrl = `http://${serverHost}:${serverPort}`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`
|
||||
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`
|
||||
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
|
||||
@@ -3,7 +3,6 @@ import { encodeFilePath } from "@/context/file/path"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
@@ -192,59 +191,6 @@ const FileTreeNode = (
|
||||
)
|
||||
}
|
||||
|
||||
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
|
||||
if (!props.enabled) return props.children
|
||||
|
||||
const parts = props.node.path.split("/")
|
||||
const leaf = parts[parts.length - 1] ?? props.node.path
|
||||
const head = parts.slice(0, -1).join("/")
|
||||
const prefix = head ? `${head}/` : ""
|
||||
const label =
|
||||
props.kind === "add"
|
||||
? "Additions"
|
||||
: props.kind === "del"
|
||||
? "Deletions"
|
||||
: props.kind === "mix"
|
||||
? "Modifications"
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
openDelay={2000}
|
||||
placement="bottom-start"
|
||||
class="w-full"
|
||||
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
||||
value={
|
||||
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
||||
<span
|
||||
class="min-w-0 truncate text-text-invert-base"
|
||||
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
||||
<Show when={label}>
|
||||
{(text) => (
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{text()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.node.type === "directory" && props.node.ignored}>
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileTree(props: {
|
||||
path: string
|
||||
class?: string
|
||||
@@ -255,7 +201,6 @@ export default function FileTree(props: {
|
||||
modified?: readonly string[]
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
tooltip?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
|
||||
_filter?: Filter
|
||||
@@ -267,7 +212,6 @@ export default function FileTree(props: {
|
||||
const file = useFile()
|
||||
const level = props.level ?? 0
|
||||
const draggable = () => props.draggable ?? true
|
||||
const tooltip = () => props.tooltip ?? true
|
||||
|
||||
const key = (p: string) =>
|
||||
file
|
||||
@@ -467,21 +411,19 @@ export default function FileTree(props: {
|
||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center text-icon-weak">
|
||||
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
||||
</div>
|
||||
</FileTreeNode>
|
||||
</FileTreeNodeTooltip>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center text-icon-weak">
|
||||
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
||||
</div>
|
||||
</FileTreeNode>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="relative pt-0.5">
|
||||
<div
|
||||
@@ -504,7 +446,6 @@ export default function FileTree(props: {
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
tooltip={props.tooltip}
|
||||
onFileClick={props.onFileClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
@@ -517,53 +458,51 @@ export default function FileTree(props: {
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={node.type === "file"}>
|
||||
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<div class="w-4 shrink-0" />
|
||||
<Switch>
|
||||
<Match when={node.ignored}>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<div class="w-4 shrink-0" />
|
||||
<Switch>
|
||||
<Match when={node.ignored}>
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono"
|
||||
style="color: var(--icon-weak-base)"
|
||||
mono
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active()}>
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono"
|
||||
style={kindTextColor(kind()!)}
|
||||
mono
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!node.ignored}>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono"
|
||||
style="color: var(--icon-weak-base)"
|
||||
mono
|
||||
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active()}>
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono"
|
||||
style={kindTextColor(kind()!)}
|
||||
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
|
||||
mono
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!node.ignored}>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
|
||||
/>
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
|
||||
mono
|
||||
/>
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</FileTreeNode>
|
||||
</FileTreeNodeTooltip>
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</FileTreeNode>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
|
||||
const OPEN_APPS = [
|
||||
@@ -45,32 +45,67 @@ type OpenApp = (typeof OPEN_APPS)[number]
|
||||
type OS = "macos" | "windows" | "linux" | "unknown"
|
||||
|
||||
const MAC_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
|
||||
{
|
||||
id: "vscode",
|
||||
label: "VS Code",
|
||||
icon: "vscode",
|
||||
openWith: "Visual Studio Code",
|
||||
},
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
||||
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
||||
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
|
||||
{
|
||||
id: "antigravity",
|
||||
label: "Antigravity",
|
||||
icon: "antigravity",
|
||||
openWith: "Antigravity",
|
||||
},
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
||||
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
{
|
||||
id: "android-studio",
|
||||
label: "Android Studio",
|
||||
icon: "android-studio",
|
||||
openWith: "Android Studio",
|
||||
},
|
||||
{
|
||||
id: "sublime-text",
|
||||
label: "Sublime Text",
|
||||
icon: "sublime-text",
|
||||
openWith: "Sublime Text",
|
||||
},
|
||||
] as const
|
||||
|
||||
const WINDOWS_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
{
|
||||
id: "powershell",
|
||||
label: "PowerShell",
|
||||
icon: "powershell",
|
||||
openWith: "powershell",
|
||||
},
|
||||
{
|
||||
id: "sublime-text",
|
||||
label: "Sublime Text",
|
||||
icon: "sublime-text",
|
||||
openWith: "Sublime Text",
|
||||
},
|
||||
] as const
|
||||
|
||||
const LINUX_APPS = [
|
||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||
{
|
||||
id: "sublime-text",
|
||||
label: "Sublime Text",
|
||||
icon: "sublime-text",
|
||||
openWith: "Sublime Text",
|
||||
},
|
||||
] as const
|
||||
|
||||
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
|
||||
@@ -213,7 +248,9 @@ export function SessionHeader() {
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
})
|
||||
|
||||
const apps = createMemo(() => {
|
||||
if (os() === "macos") return MAC_APPS
|
||||
@@ -259,18 +296,34 @@ export function SessionHeader() {
|
||||
|
||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
||||
const [menu, setMenu] = createStore({ open: false })
|
||||
const [openRequest, setOpenRequest] = createStore({
|
||||
app: undefined as OpenApp | undefined,
|
||||
})
|
||||
|
||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
|
||||
createEffect(() => {
|
||||
const value = prefs.app
|
||||
if (options().some((o) => o.id === value)) return
|
||||
setPrefs("app", options()[0]?.id ?? "finder")
|
||||
})
|
||||
|
||||
const openDir = (app: OpenApp) => {
|
||||
if (opening() || !canOpen() || !platform.openPath) return
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return
|
||||
if (!canOpen()) return
|
||||
|
||||
const item = options().find((o) => o.id === app)
|
||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err))
|
||||
setOpenRequest("app", app)
|
||||
platform
|
||||
.openPath(directory, openWith)
|
||||
.catch((err: unknown) => showRequestError(language, err))
|
||||
.finally(() => {
|
||||
setOpenRequest("app", undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const copyPath = () => {
|
||||
@@ -315,7 +368,9 @@ export function SessionHeader() {
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
|
||||
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
|
||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||
{language.t("session.header.search.placeholder", { project: name() })}
|
||||
{language.t("session.header.search.placeholder", {
|
||||
project: name(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -357,12 +412,21 @@ export function SessionHeader() {
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
onClick={() => openDir(current().id)}
|
||||
disabled={opening()}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<AppIcon id={current().icon} class="size-4" />
|
||||
<Show
|
||||
when={opening()}
|
||||
fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
|
||||
>
|
||||
<Spinner class="size-3.5 text-icon-base" />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">Open</span>
|
||||
</Button>
|
||||
@@ -377,7 +441,11 @@ export function SessionHeader() {
|
||||
as={IconButton}
|
||||
icon="chevron-down"
|
||||
variant="ghost"
|
||||
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-hover"
|
||||
disabled={opening()}
|
||||
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
@@ -395,6 +463,7 @@ export function SessionHeader() {
|
||||
{(o) => (
|
||||
<DropdownMenu.RadioItem
|
||||
value={o.id}
|
||||
disabled={opening()}
|
||||
onSelect={() => {
|
||||
setMenu("open", false)
|
||||
openDir(o.id)
|
||||
|
||||
@@ -15,10 +15,10 @@ describe("file path helpers", () => {
|
||||
|
||||
test("normalizes Windows absolute paths with mixed separators", () => {
|
||||
const path = createPathHelpers(() => "C:\\repo")
|
||||
expect(path.normalize("C:\\repo\\src\\app.ts")).toBe("src/app.ts")
|
||||
expect(path.normalize("C:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
||||
expect(path.normalize("C:/repo/src/app.ts")).toBe("src/app.ts")
|
||||
expect(path.normalize("file://C:/repo/src/app.ts")).toBe("src/app.ts")
|
||||
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src/app.ts")
|
||||
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
||||
})
|
||||
|
||||
test("keeps query/hash stripping behavior stable", () => {
|
||||
|
||||
@@ -103,32 +103,30 @@ export function encodeFilePath(filepath: string): string {
|
||||
|
||||
export function createPathHelpers(scope: () => string) {
|
||||
const normalize = (input: string) => {
|
||||
const root = scope().replace(/\\/g, "/")
|
||||
const root = scope()
|
||||
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(/\\/g, "/")
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
|
||||
|
||||
// Remove initial root prefix, if it's a complete match or followed by /
|
||||
// (don't want /foo/bar to root of /f).
|
||||
// For Windows paths, also check for case-insensitive match.
|
||||
const windows = /^[A-Za-z]:/.test(root)
|
||||
const canonRoot = windows ? root.toLowerCase() : root
|
||||
const canonPath = windows ? path.toLowerCase() : path
|
||||
// Separator-agnostic prefix stripping for Cygwin/native Windows compatibility
|
||||
// Only case-insensitive on Windows (drive letter or UNC paths)
|
||||
const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\")
|
||||
const canonRoot = windows ? root.replace(/\\/g, "/").toLowerCase() : root.replace(/\\/g, "/")
|
||||
const canonPath = windows ? path.replace(/\\/g, "/").toLowerCase() : path.replace(/\\/g, "/")
|
||||
if (
|
||||
canonPath.startsWith(canonRoot) &&
|
||||
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath.startsWith(canonRoot + "/"))
|
||||
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/")
|
||||
) {
|
||||
// If we match canonRoot + "/", the slash will be removed below.
|
||||
// Slice from original path to preserve native separators
|
||||
path = path.slice(root.length)
|
||||
}
|
||||
|
||||
if (path.startsWith("./")) {
|
||||
if (path.startsWith("./") || path.startsWith(".\\")) {
|
||||
path = path.slice(2)
|
||||
}
|
||||
|
||||
if (path.startsWith("/")) {
|
||||
if (path.startsWith("/") || path.startsWith("\\")) {
|
||||
path = path.slice(1)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
const coalesced = new Map<string, number>()
|
||||
const staleDeltas = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||
|
||||
const key = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||
@@ -68,14 +71,20 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
staleDeltas.clear()
|
||||
|
||||
last = Date.now()
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
if (skip && event.payload.type === "message.part.delta") {
|
||||
const props = event.payload.properties
|
||||
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
|
||||
}
|
||||
emitter.emit(event.directory, event.payload)
|
||||
}
|
||||
})
|
||||
@@ -144,6 +153,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload }
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||
}
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { sanitizeProject } from "./global-sync/utils"
|
||||
import { usePlatform } from "./platform"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -51,12 +52,6 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error) return error
|
||||
return "Unknown error"
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
@@ -207,8 +202,9 @@ function createGlobalSync() {
|
||||
console.error("Failed to load sessions", err)
|
||||
const project = getFilename(directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.listFailed.title", { project }),
|
||||
description: errorMessage(err),
|
||||
description: formatServerError(err),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { batch } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { cmp, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -133,8 +134,11 @@ export async function bootstrapDirectory(input: {
|
||||
} catch (err) {
|
||||
console.error("Failed to bootstrap instance", err)
|
||||
const project = getFilename(input.directory)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: `Failed to reload ${project}`, description: message })
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: `Failed to reload ${project}`,
|
||||
description: formatServerError(err),
|
||||
})
|
||||
input.setStore("status", "partial")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": !store.collapsed }}
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -371,6 +371,12 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
const cancelCommenting = () => {
|
||||
const p = path()
|
||||
if (p) file.setSelectedLines(p, null)
|
||||
setNote("commenting", null)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => state()?.loaded,
|
||||
@@ -484,7 +490,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
value={note.draft}
|
||||
selection={formatCommentLabel(range())}
|
||||
onInput={(value) => setNote("draft", value)}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onCancel={cancelCommenting}
|
||||
onSubmit={(value) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
@@ -498,7 +504,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||
setCommenting(null)
|
||||
cancelCommenting()
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("createOpenReviewFile", () => {
|
||||
|
||||
openReviewFile("src/a.ts")
|
||||
|
||||
expect(calls).toEqual(["show", "tab:src/a.ts", "open:file://src/a.ts", "load:src/a.ts"])
|
||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -24,13 +24,15 @@ export const createOpenReviewFile = (input: {
|
||||
showAllFiles: () => void
|
||||
tabForPath: (path: string) => string
|
||||
openTab: (tab: string) => void
|
||||
loadFile: (path: string) => void
|
||||
loadFile: (path: string) => any | Promise<void>
|
||||
}) => {
|
||||
return (path: string) => {
|
||||
batch(() => {
|
||||
input.showAllFiles()
|
||||
input.openTab(input.tabForPath(path))
|
||||
input.loadFile(path)
|
||||
const maybePromise = input.loadFile(path)
|
||||
const openTab = () => input.openTab(input.tabForPath(path))
|
||||
if (maybePromise instanceof Promise) maybePromise.then(openTab)
|
||||
else openTab()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
69
packages/app/src/utils/server-errors.test.ts
Normal file
69
packages/app/src/utils/server-errors.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ConfigInvalidError } from "./server-errors"
|
||||
import { formatServerError, parseReabaleConfigInvalidError } from "./server-errors"
|
||||
|
||||
describe("parseReabaleConfigInvalidError", () => {
|
||||
test("formats issues with file path", () => {
|
||||
const error = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "opencode.config.ts",
|
||||
issues: [
|
||||
{ path: ["settings", "host"], message: "Required" },
|
||||
{ path: ["mode"], message: "Invalid" },
|
||||
],
|
||||
},
|
||||
} satisfies ConfigInvalidError
|
||||
|
||||
const result = parseReabaleConfigInvalidError(error)
|
||||
|
||||
expect(result).toBe(
|
||||
["Invalid configuration", "opencode.config.ts", "settings.host: Required", "mode: Invalid"].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("uses trimmed message when issues are missing", () => {
|
||||
const error = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "config",
|
||||
message: " Bad value ",
|
||||
},
|
||||
} satisfies ConfigInvalidError
|
||||
|
||||
const result = parseReabaleConfigInvalidError(error)
|
||||
|
||||
expect(result).toBe(["Invalid configuration", "Bad value"].join("\n"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatServerError", () => {
|
||||
test("formats config invalid errors", () => {
|
||||
const error = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
message: "Missing host",
|
||||
},
|
||||
} satisfies ConfigInvalidError
|
||||
|
||||
const result = formatServerError(error)
|
||||
|
||||
expect(result).toBe(["Invalid configuration", "Missing host"].join("\n"))
|
||||
})
|
||||
|
||||
test("returns error messages", () => {
|
||||
expect(formatServerError(new Error("Request failed with status 503"))).toBe("Request failed with status 503")
|
||||
})
|
||||
|
||||
test("returns provided string errors", () => {
|
||||
expect(formatServerError("Failed to connect to server")).toBe("Failed to connect to server")
|
||||
})
|
||||
|
||||
test("falls back to unknown", () => {
|
||||
expect(formatServerError(0)).toBe("Unknown error")
|
||||
})
|
||||
|
||||
test("falls back for unknown error objects and names", () => {
|
||||
expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } })).toBe("Unknown error")
|
||||
})
|
||||
})
|
||||
32
packages/app/src/utils/server-errors.ts
Normal file
32
packages/app/src/utils/server-errors.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type ConfigInvalidError = {
|
||||
name: "ConfigInvalidError"
|
||||
data: {
|
||||
path?: string
|
||||
message?: string
|
||||
issues?: Array<{ message: string; path: string[] }>
|
||||
}
|
||||
}
|
||||
|
||||
export function formatServerError(error: unknown) {
|
||||
if (isConfigInvalidErrorLike(error)) return parseReabaleConfigInvalidError(error)
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error) return error
|
||||
return "Unknown error"
|
||||
}
|
||||
|
||||
function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
const o = error as Record<string, unknown>
|
||||
return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null
|
||||
}
|
||||
|
||||
export function parseReabaleConfigInvalidError(errorInput: ConfigInvalidError) {
|
||||
const head = "Invalid configuration"
|
||||
const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : ""
|
||||
const detail = errorInput.data.message?.trim() ?? ""
|
||||
const issues = (errorInput.data.issues ?? []).map((issue) => {
|
||||
return `${issue.path.join(".")}: ${issue.message}`
|
||||
})
|
||||
if (issues.length) return [head, file, "", ...issues].filter(Boolean).join("\n")
|
||||
return [head, file, detail].filter(Boolean).join("\n")
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
|
||||
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vite start"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -243,6 +243,7 @@ export const dict = {
|
||||
"black.hero.title": "الوصول إلى أفضل نماذج البرمجة في العالم",
|
||||
"black.hero.subtitle": "بما في ذلك Claude، GPT، Gemini والمزيد",
|
||||
"black.title": "OpenCode Black | الأسعار",
|
||||
"black.paused": "التسجيل في خطة Black متوقف مؤقتًا.",
|
||||
"black.plan.icon20": "خطة Black 20",
|
||||
"black.plan.icon100": "خطة Black 100",
|
||||
"black.plan.icon200": "خطة Black 200",
|
||||
@@ -343,7 +344,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "كتابة الكاش",
|
||||
"workspace.usage.breakdown.output": "الخرج",
|
||||
"workspace.usage.breakdown.reasoning": "المنطق",
|
||||
"workspace.usage.subscription": "الاشتراك (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "التكلفة",
|
||||
"workspace.cost.subtitle": "تكاليف الاستخدام مقسمة حسب النموذج.",
|
||||
@@ -352,6 +355,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(محذوف)",
|
||||
"workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.",
|
||||
"workspace.cost.subscriptionShort": "اشتراك",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "مفاتيح API",
|
||||
"workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.",
|
||||
@@ -479,6 +483,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrolled": "مسجل",
|
||||
"workspace.black.waitlist.enrollNote": 'عند النقر فوق "تسجيل"، يبدأ اشتراكك على الفور وسيتم خصم الرسوم من بطاقتك.',
|
||||
|
||||
"workspace.lite.loading": "جارٍ التحميل...",
|
||||
"workspace.lite.time.day": "يوم",
|
||||
"workspace.lite.time.days": "أيام",
|
||||
"workspace.lite.time.hour": "ساعة",
|
||||
"workspace.lite.time.hours": "ساعات",
|
||||
"workspace.lite.time.minute": "دقيقة",
|
||||
"workspace.lite.time.minutes": "دقائق",
|
||||
"workspace.lite.time.fewSeconds": "بضع ثوان",
|
||||
"workspace.lite.subscription.title": "اشتراك Go",
|
||||
"workspace.lite.subscription.message": "أنت مشترك في OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "إدارة الاشتراك",
|
||||
"workspace.lite.subscription.rollingUsage": "الاستخدام المتجدد",
|
||||
"workspace.lite.subscription.weeklyUsage": "الاستخدام الأسبوعي",
|
||||
"workspace.lite.subscription.monthlyUsage": "الاستخدام الشهري",
|
||||
"workspace.lite.subscription.resetsIn": "إعادة تعيين في",
|
||||
"workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.',
|
||||
"workspace.lite.other.title": "اشتراك Go",
|
||||
"workspace.lite.other.message":
|
||||
"عضو آخر في مساحة العمل هذه مشترك بالفعل في OpenCode Go. يمكن لعضو واحد فقط لكل مساحة عمل الاشتراك.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go هو اشتراك بسعر $10 شهريًا يوفر وصولاً موثوقًا إلى نماذج البرمجة المفتوحة الشائعة مع حدود استخدام سخية.",
|
||||
"workspace.lite.promo.modelsTitle": "ما يتضمنه",
|
||||
"workspace.lite.promo.footer":
|
||||
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.",
|
||||
"workspace.lite.promo.subscribe": "الاشتراك في Go",
|
||||
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
|
||||
|
||||
"download.title": "OpenCode | تنزيل",
|
||||
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
||||
"download.hero.title": "تنزيل OpenCode",
|
||||
|
||||
@@ -247,6 +247,7 @@ export const dict = {
|
||||
"black.hero.title": "Acesse os melhores modelos de codificação do mundo",
|
||||
"black.hero.subtitle": "Incluindo Claude, GPT, Gemini e mais",
|
||||
"black.title": "OpenCode Black | Preços",
|
||||
"black.paused": "A inscrição no plano Black está temporariamente pausada.",
|
||||
"black.plan.icon20": "Plano Black 20",
|
||||
"black.plan.icon100": "Plano Black 100",
|
||||
"black.plan.icon200": "Plano Black 200",
|
||||
@@ -348,7 +349,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Escrita em Cache",
|
||||
"workspace.usage.breakdown.output": "Saída",
|
||||
"workspace.usage.breakdown.reasoning": "Raciocínio",
|
||||
"workspace.usage.subscription": "assinatura (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Custo",
|
||||
"workspace.cost.subtitle": "Custos de uso discriminados por modelo.",
|
||||
@@ -357,6 +360,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(excluído)",
|
||||
"workspace.cost.empty": "Nenhum dado de uso disponível para o período selecionado.",
|
||||
"workspace.cost.subscriptionShort": "ass",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Chaves de API",
|
||||
"workspace.keys.subtitle": "Gerencie suas chaves de API para acessar os serviços opencode.",
|
||||
@@ -485,6 +489,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Ao clicar em Inscrever-se, sua assinatura começará imediatamente e seu cartão será cobrado.",
|
||||
|
||||
"workspace.lite.loading": "Carregando...",
|
||||
"workspace.lite.time.day": "dia",
|
||||
"workspace.lite.time.days": "dias",
|
||||
"workspace.lite.time.hour": "hora",
|
||||
"workspace.lite.time.hours": "horas",
|
||||
"workspace.lite.time.minute": "minuto",
|
||||
"workspace.lite.time.minutes": "minutos",
|
||||
"workspace.lite.time.fewSeconds": "alguns segundos",
|
||||
"workspace.lite.subscription.title": "Assinatura Go",
|
||||
"workspace.lite.subscription.message": "Você assina o OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "Gerenciar Assinatura",
|
||||
"workspace.lite.subscription.rollingUsage": "Uso Contínuo",
|
||||
"workspace.lite.subscription.weeklyUsage": "Uso Semanal",
|
||||
"workspace.lite.subscription.monthlyUsage": "Uso Mensal",
|
||||
"workspace.lite.subscription.resetsIn": "Reinicia em",
|
||||
"workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.',
|
||||
"workspace.lite.other.title": "Assinatura Go",
|
||||
"workspace.lite.other.message":
|
||||
"Outro membro neste workspace já assina o OpenCode Go. Apenas um membro por workspace pode assinar.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"O OpenCode Go é uma assinatura de $10 por mês que fornece acesso confiável a modelos abertos de codificação populares com limites de uso generosos.",
|
||||
"workspace.lite.promo.modelsTitle": "O que está incluído",
|
||||
"workspace.lite.promo.footer":
|
||||
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.",
|
||||
"workspace.lite.promo.subscribe": "Assinar Go",
|
||||
"workspace.lite.promo.subscribing": "Redirecionando...",
|
||||
|
||||
"download.title": "OpenCode | Baixar",
|
||||
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
||||
"download.hero.title": "Baixar OpenCode",
|
||||
|
||||
@@ -245,6 +245,7 @@ export const dict = {
|
||||
"black.hero.title": "Få adgang til verdens bedste kodningsmodeller",
|
||||
"black.hero.subtitle": "Inklusive Claude, GPT, Gemini og mere",
|
||||
"black.title": "OpenCode Black | Priser",
|
||||
"black.paused": "Black-plantilmelding er midlertidigt sat på pause.",
|
||||
"black.plan.icon20": "Black 20-plan",
|
||||
"black.plan.icon100": "Black 100-plan",
|
||||
"black.plan.icon200": "Black 200-plan",
|
||||
@@ -346,7 +347,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Cache skriv",
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Ræsonnement",
|
||||
"workspace.usage.subscription": "abonnement (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Omkostninger",
|
||||
"workspace.cost.subtitle": "Brugsomkostninger opdelt efter model.",
|
||||
@@ -355,6 +358,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(slettet)",
|
||||
"workspace.cost.empty": "Ingen brugsdata tilgængelige for den valgte periode.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API-nøgler",
|
||||
"workspace.keys.subtitle": "Administrer dine API-nøgler for at få adgang til opencode-tjenester.",
|
||||
@@ -483,6 +487,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Når du klikker på Tilmeld, starter dit abonnement med det samme, og dit kort vil blive debiteret.",
|
||||
|
||||
"workspace.lite.loading": "Indlæser...",
|
||||
"workspace.lite.time.day": "dag",
|
||||
"workspace.lite.time.days": "dage",
|
||||
"workspace.lite.time.hour": "time",
|
||||
"workspace.lite.time.hours": "timer",
|
||||
"workspace.lite.time.minute": "minut",
|
||||
"workspace.lite.time.minutes": "minutter",
|
||||
"workspace.lite.time.fewSeconds": "et par sekunder",
|
||||
"workspace.lite.subscription.title": "Go-abonnement",
|
||||
"workspace.lite.subscription.message": "Du abonnerer på OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "Administrer abonnement",
|
||||
"workspace.lite.subscription.rollingUsage": "Løbende forbrug",
|
||||
"workspace.lite.subscription.weeklyUsage": "Ugentligt forbrug",
|
||||
"workspace.lite.subscription.monthlyUsage": "Månedligt forbrug",
|
||||
"workspace.lite.subscription.resetsIn": "Nulstiller i",
|
||||
"workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.',
|
||||
"workspace.lite.other.title": "Go-abonnement",
|
||||
"workspace.lite.other.message":
|
||||
"Et andet medlem i dette workspace abonnerer allerede på OpenCode Go. Kun ét medlem pr. workspace kan abonnere.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go er et abonnement til $10 om måneden, der giver pålidelig adgang til populære åbne kodningsmodeller med generøse forbrugsgrænser.",
|
||||
"workspace.lite.promo.modelsTitle": "Hvad er inkluderet",
|
||||
"workspace.lite.promo.footer":
|
||||
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.",
|
||||
"workspace.lite.promo.subscribe": "Abonner på Go",
|
||||
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
||||
"download.hero.title": "Download OpenCode",
|
||||
|
||||
@@ -247,6 +247,7 @@ export const dict = {
|
||||
"black.hero.title": "Zugriff auf die weltweit besten Coding-Modelle",
|
||||
"black.hero.subtitle": "Einschließlich Claude, GPT, Gemini und mehr",
|
||||
"black.title": "OpenCode Black | Preise",
|
||||
"black.paused": "Die Anmeldung zum Black-Plan ist vorübergehend pausiert.",
|
||||
"black.plan.icon20": "Black 20 Plan",
|
||||
"black.plan.icon100": "Black 100 Plan",
|
||||
"black.plan.icon200": "Black 200 Plan",
|
||||
@@ -348,7 +349,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Cache Write",
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning",
|
||||
"workspace.usage.subscription": "Abonnement (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Kosten",
|
||||
"workspace.cost.subtitle": "Nutzungskosten aufgeschlüsselt nach Modell.",
|
||||
@@ -357,6 +360,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(gelöscht)",
|
||||
"workspace.cost.empty": "Keine Nutzungsdaten für den gewählten Zeitraum verfügbar.",
|
||||
"workspace.cost.subscriptionShort": "Abo",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "Verwalte deine API Keys für den Zugriff auf OpenCode-Dienste.",
|
||||
@@ -485,6 +489,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Wenn du auf Einschreiben klickst, startet dein Abo sofort und deine Karte wird belastet.",
|
||||
|
||||
"workspace.lite.loading": "Lade...",
|
||||
"workspace.lite.time.day": "Tag",
|
||||
"workspace.lite.time.days": "Tage",
|
||||
"workspace.lite.time.hour": "Stunde",
|
||||
"workspace.lite.time.hours": "Stunden",
|
||||
"workspace.lite.time.minute": "Minute",
|
||||
"workspace.lite.time.minutes": "Minuten",
|
||||
"workspace.lite.time.fewSeconds": "einige Sekunden",
|
||||
"workspace.lite.subscription.title": "Go-Abonnement",
|
||||
"workspace.lite.subscription.message": "Du hast OpenCode Go abonniert.",
|
||||
"workspace.lite.subscription.manage": "Abo verwalten",
|
||||
"workspace.lite.subscription.rollingUsage": "Fortlaufende Nutzung",
|
||||
"workspace.lite.subscription.weeklyUsage": "Wöchentliche Nutzung",
|
||||
"workspace.lite.subscription.monthlyUsage": "Monatliche Nutzung",
|
||||
"workspace.lite.subscription.resetsIn": "Setzt zurück in",
|
||||
"workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.',
|
||||
"workspace.lite.other.title": "Go-Abonnement",
|
||||
"workspace.lite.other.message":
|
||||
"Ein anderes Mitglied in diesem Workspace hat OpenCode Go bereits abonniert. Nur ein Mitglied pro Workspace kann abonnieren.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go ist ein Abonnement für $10 pro Monat, das zuverlässigen Zugriff auf beliebte offene Coding-Modelle mit großzügigen Nutzungslimits bietet.",
|
||||
"workspace.lite.promo.modelsTitle": "Was enthalten ist",
|
||||
"workspace.lite.promo.footer":
|
||||
"Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.",
|
||||
"workspace.lite.promo.subscribe": "Go abonnieren",
|
||||
"workspace.lite.promo.subscribing": "Leite weiter...",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
||||
"download.hero.title": "OpenCode herunterladen",
|
||||
|
||||
@@ -239,6 +239,7 @@ export const dict = {
|
||||
"black.hero.title": "Access all the world's best coding models",
|
||||
"black.hero.subtitle": "Including Claude, GPT, Gemini and more",
|
||||
"black.title": "OpenCode Black | Pricing",
|
||||
"black.paused": "Black plan enrollment is temporarily paused.",
|
||||
"black.plan.icon20": "Black 20 plan",
|
||||
"black.plan.icon100": "Black 100 plan",
|
||||
"black.plan.icon200": "Black 200 plan",
|
||||
@@ -340,7 +341,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Cache Write",
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning",
|
||||
"workspace.usage.subscription": "subscription (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Cost",
|
||||
"workspace.cost.subtitle": "Usage costs broken down by model.",
|
||||
@@ -349,6 +352,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(deleted)",
|
||||
"workspace.cost.empty": "No usage data available for the selected period.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "Manage your API keys for accessing opencode services.",
|
||||
@@ -477,6 +481,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"When you click Enroll, your subscription starts immediately and your card will be charged.",
|
||||
|
||||
"workspace.lite.loading": "Loading...",
|
||||
"workspace.lite.time.day": "day",
|
||||
"workspace.lite.time.days": "days",
|
||||
"workspace.lite.time.hour": "hour",
|
||||
"workspace.lite.time.hours": "hours",
|
||||
"workspace.lite.time.minute": "minute",
|
||||
"workspace.lite.time.minutes": "minutes",
|
||||
"workspace.lite.time.fewSeconds": "a few seconds",
|
||||
"workspace.lite.subscription.title": "Go Subscription",
|
||||
"workspace.lite.subscription.message": "You are subscribed to OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "Manage Subscription",
|
||||
"workspace.lite.subscription.rollingUsage": "Rolling Usage",
|
||||
"workspace.lite.subscription.weeklyUsage": "Weekly Usage",
|
||||
"workspace.lite.subscription.monthlyUsage": "Monthly Usage",
|
||||
"workspace.lite.subscription.resetsIn": "Resets in",
|
||||
"workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.',
|
||||
"workspace.lite.other.title": "Go Subscription",
|
||||
"workspace.lite.other.message":
|
||||
"Another member in this workspace is already subscribed to OpenCode Go. Only one member per workspace can subscribe.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models with generous usage limits.",
|
||||
"workspace.lite.promo.modelsTitle": "What's Included",
|
||||
"workspace.lite.promo.footer":
|
||||
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.",
|
||||
"workspace.lite.promo.subscribe": "Subscribe to Go",
|
||||
"workspace.lite.promo.subscribing": "Redirecting...",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
||||
"download.hero.title": "Download OpenCode",
|
||||
|
||||
@@ -248,6 +248,7 @@ export const dict = {
|
||||
"black.hero.title": "Accede a los mejores modelos de codificación del mundo",
|
||||
"black.hero.subtitle": "Incluyendo Claude, GPT, Gemini y más",
|
||||
"black.title": "OpenCode Black | Precios",
|
||||
"black.paused": "La inscripción al plan Black está temporalmente pausada.",
|
||||
"black.plan.icon20": "Plan Black 20",
|
||||
"black.plan.icon100": "Plan Black 100",
|
||||
"black.plan.icon200": "Plan Black 200",
|
||||
@@ -349,7 +350,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Escritura de Caché",
|
||||
"workspace.usage.breakdown.output": "Salida",
|
||||
"workspace.usage.breakdown.reasoning": "Razonamiento",
|
||||
"workspace.usage.subscription": "suscripción (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Costo",
|
||||
"workspace.cost.subtitle": "Costos de uso desglosados por modelo.",
|
||||
@@ -358,6 +361,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(eliminado)",
|
||||
"workspace.cost.empty": "No hay datos de uso disponibles para el periodo seleccionado.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Claves API",
|
||||
"workspace.keys.subtitle": "Gestiona tus claves API para acceder a los servicios de opencode.",
|
||||
@@ -486,6 +490,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Cuando haces clic en Inscribirse, tu suscripción comienza inmediatamente y se cargará a tu tarjeta.",
|
||||
|
||||
"workspace.lite.loading": "Cargando...",
|
||||
"workspace.lite.time.day": "día",
|
||||
"workspace.lite.time.days": "días",
|
||||
"workspace.lite.time.hour": "hora",
|
||||
"workspace.lite.time.hours": "horas",
|
||||
"workspace.lite.time.minute": "minuto",
|
||||
"workspace.lite.time.minutes": "minutos",
|
||||
"workspace.lite.time.fewSeconds": "unos pocos segundos",
|
||||
"workspace.lite.subscription.title": "Suscripción Go",
|
||||
"workspace.lite.subscription.message": "Estás suscrito a OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "Gestionar Suscripción",
|
||||
"workspace.lite.subscription.rollingUsage": "Uso Continuo",
|
||||
"workspace.lite.subscription.weeklyUsage": "Uso Semanal",
|
||||
"workspace.lite.subscription.monthlyUsage": "Uso Mensual",
|
||||
"workspace.lite.subscription.resetsIn": "Se reinicia en",
|
||||
"workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.',
|
||||
"workspace.lite.other.title": "Suscripción Go",
|
||||
"workspace.lite.other.message":
|
||||
"Otro miembro de este espacio de trabajo ya está suscrito a OpenCode Go. Solo un miembro por espacio de trabajo puede suscribirse.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go es una suscripción de $10 al mes que proporciona acceso confiable a modelos de codificación abiertos populares con generosos límites de uso.",
|
||||
"workspace.lite.promo.modelsTitle": "Qué incluye",
|
||||
"workspace.lite.promo.footer":
|
||||
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.",
|
||||
"workspace.lite.promo.subscribe": "Suscribirse a Go",
|
||||
"workspace.lite.promo.subscribing": "Redirigiendo...",
|
||||
|
||||
"download.title": "OpenCode | Descargar",
|
||||
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
||||
"download.hero.title": "Descargar OpenCode",
|
||||
|
||||
@@ -251,6 +251,7 @@ export const dict = {
|
||||
"black.hero.title": "Accédez aux meilleurs modèles de code au monde",
|
||||
"black.hero.subtitle": "Y compris Claude, GPT, Gemini et plus",
|
||||
"black.title": "OpenCode Black | Tarification",
|
||||
"black.paused": "L'inscription au plan Black est temporairement suspendue.",
|
||||
"black.plan.icon20": "Forfait Black 20",
|
||||
"black.plan.icon100": "Forfait Black 100",
|
||||
"black.plan.icon200": "Forfait Black 200",
|
||||
@@ -354,7 +355,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Écriture cache",
|
||||
"workspace.usage.breakdown.output": "Sortie",
|
||||
"workspace.usage.breakdown.reasoning": "Raisonnement",
|
||||
"workspace.usage.subscription": "abonnement ({{amount}} $)",
|
||||
"workspace.usage.subscription": "Black ({{amount}} $)",
|
||||
"workspace.usage.lite": "Go ({{amount}} $)",
|
||||
"workspace.usage.byok": "BYOK ({{amount}} $)",
|
||||
|
||||
"workspace.cost.title": "Coût",
|
||||
"workspace.cost.subtitle": "Coûts d'utilisation répartis par modèle.",
|
||||
@@ -363,6 +366,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(supprimé)",
|
||||
"workspace.cost.empty": "Aucune donnée d'utilisation disponible pour la période sélectionnée.",
|
||||
"workspace.cost.subscriptionShort": "abo",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Clés API",
|
||||
"workspace.keys.subtitle": "Gérez vos clés API pour accéder aux services OpenCode.",
|
||||
@@ -494,6 +498,37 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Lorsque vous cliquez sur S'inscrire, votre abonnement démarre immédiatement et votre carte sera débitée.",
|
||||
|
||||
"workspace.lite.loading": "Chargement...",
|
||||
"workspace.lite.time.day": "jour",
|
||||
"workspace.lite.time.days": "jours",
|
||||
"workspace.lite.time.hour": "heure",
|
||||
"workspace.lite.time.hours": "heures",
|
||||
"workspace.lite.time.minute": "minute",
|
||||
"workspace.lite.time.minutes": "minutes",
|
||||
"workspace.lite.time.fewSeconds": "quelques secondes",
|
||||
"workspace.lite.subscription.title": "Abonnement Go",
|
||||
"workspace.lite.subscription.message": "Vous êtes abonné à OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "Gérer l'abonnement",
|
||||
"workspace.lite.subscription.rollingUsage": "Utilisation glissante",
|
||||
"workspace.lite.subscription.weeklyUsage": "Utilisation hebdomadaire",
|
||||
"workspace.lite.subscription.monthlyUsage": "Utilisation mensuelle",
|
||||
"workspace.lite.subscription.resetsIn": "Réinitialisation dans",
|
||||
"workspace.lite.subscription.useBalance":
|
||||
"Utilisez votre solde disponible après avoir atteint les limites d'utilisation",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.',
|
||||
"workspace.lite.other.title": "Abonnement Go",
|
||||
"workspace.lite.other.message":
|
||||
"Un autre membre de cet espace de travail est déjà abonné à OpenCode Go. Un seul membre par espace de travail peut s'abonner.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go est un abonnement à 10 $ par mois qui offre un accès fiable aux modèles de codage ouverts populaires avec des limites d'utilisation généreuses.",
|
||||
"workspace.lite.promo.modelsTitle": "Ce qui est inclus",
|
||||
"workspace.lite.promo.footer":
|
||||
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.",
|
||||
"workspace.lite.promo.subscribe": "S'abonner à Go",
|
||||
"workspace.lite.promo.subscribing": "Redirection...",
|
||||
|
||||
"download.title": "OpenCode | Téléchargement",
|
||||
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
||||
"download.hero.title": "Télécharger OpenCode",
|
||||
|
||||
@@ -246,6 +246,7 @@ export const dict = {
|
||||
"black.hero.title": "Accedi ai migliori modelli di coding al mondo",
|
||||
"black.hero.subtitle": "Inclusi Claude, GPT, Gemini e altri",
|
||||
"black.title": "OpenCode Black | Prezzi",
|
||||
"black.paused": "L'iscrizione al piano Black è temporaneamente sospesa.",
|
||||
"black.plan.icon20": "Piano Black 20",
|
||||
"black.plan.icon100": "Piano Black 100",
|
||||
"black.plan.icon200": "Piano Black 200",
|
||||
@@ -348,7 +349,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Scrittura Cache",
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning",
|
||||
"workspace.usage.subscription": "abbonamento (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Costo",
|
||||
"workspace.cost.subtitle": "Costi di utilizzo suddivisi per modello.",
|
||||
@@ -357,6 +360,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(eliminato)",
|
||||
"workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Chiavi API",
|
||||
"workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.",
|
||||
@@ -485,6 +489,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Quando clicchi su Iscriviti, il tuo abbonamento inizia immediatamente e la tua carta verrà addebitata.",
|
||||
|
||||
"workspace.lite.loading": "Caricamento...",
|
||||
"workspace.lite.time.day": "giorno",
|
||||
"workspace.lite.time.days": "giorni",
|
||||
"workspace.lite.time.hour": "ora",
|
||||
"workspace.lite.time.hours": "ore",
|
||||
"workspace.lite.time.minute": "minuto",
|
||||
"workspace.lite.time.minutes": "minuti",
|
||||
"workspace.lite.time.fewSeconds": "pochi secondi",
|
||||
"workspace.lite.subscription.title": "Abbonamento Go",
|
||||
"workspace.lite.subscription.message": "Sei abbonato a OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "Gestisci Abbonamento",
|
||||
"workspace.lite.subscription.rollingUsage": "Utilizzo Continuativo",
|
||||
"workspace.lite.subscription.weeklyUsage": "Utilizzo Settimanale",
|
||||
"workspace.lite.subscription.monthlyUsage": "Utilizzo Mensile",
|
||||
"workspace.lite.subscription.resetsIn": "Si resetta tra",
|
||||
"workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.',
|
||||
"workspace.lite.other.title": "Abbonamento Go",
|
||||
"workspace.lite.other.message":
|
||||
"Un altro membro in questo workspace è già abbonato a OpenCode Go. Solo un membro per workspace può abbonarsi.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go è un abbonamento a $10 al mese che fornisce un accesso affidabile a popolari modelli di coding aperti con generosi limiti di utilizzo.",
|
||||
"workspace.lite.promo.modelsTitle": "Cosa è incluso",
|
||||
"workspace.lite.promo.footer":
|
||||
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.",
|
||||
"workspace.lite.promo.subscribe": "Abbonati a Go",
|
||||
"workspace.lite.promo.subscribing": "Reindirizzamento...",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
||||
"download.hero.title": "Scarica OpenCode",
|
||||
|
||||
@@ -244,6 +244,7 @@ export const dict = {
|
||||
"black.hero.title": "世界最高峰のコーディングモデルすべてにアクセス",
|
||||
"black.hero.subtitle": "Claude、GPT、Gemini などを含む",
|
||||
"black.title": "OpenCode Black | 料金",
|
||||
"black.paused": "Blackプランの登録は一時的に停止しています。",
|
||||
"black.plan.icon20": "Black 20 プラン",
|
||||
"black.plan.icon100": "Black 100 プラン",
|
||||
"black.plan.icon200": "Black 200 プラン",
|
||||
@@ -345,7 +346,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "キャッシュ書き込み",
|
||||
"workspace.usage.breakdown.output": "出力",
|
||||
"workspace.usage.breakdown.reasoning": "推論",
|
||||
"workspace.usage.subscription": "サブスクリプション (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "コスト",
|
||||
"workspace.cost.subtitle": "モデルごとの使用料金の内訳。",
|
||||
@@ -354,6 +357,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(削除済み)",
|
||||
"workspace.cost.empty": "選択した期間の使用状況データはありません。",
|
||||
"workspace.cost.subscriptionShort": "サブ",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "APIキー",
|
||||
"workspace.keys.subtitle": "OpenCodeサービスにアクセスするためのAPIキーを管理します。",
|
||||
@@ -483,6 +487,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"「登録する」をクリックすると、サブスクリプションがすぐに開始され、カードに請求されます。",
|
||||
|
||||
"workspace.lite.loading": "読み込み中...",
|
||||
"workspace.lite.time.day": "日",
|
||||
"workspace.lite.time.days": "日",
|
||||
"workspace.lite.time.hour": "時間",
|
||||
"workspace.lite.time.hours": "時間",
|
||||
"workspace.lite.time.minute": "分",
|
||||
"workspace.lite.time.minutes": "分",
|
||||
"workspace.lite.time.fewSeconds": "数秒",
|
||||
"workspace.lite.subscription.title": "Goサブスクリプション",
|
||||
"workspace.lite.subscription.message": "あなたは OpenCode Go を購読しています。",
|
||||
"workspace.lite.subscription.manage": "サブスクリプションの管理",
|
||||
"workspace.lite.subscription.rollingUsage": "ローリング利用量",
|
||||
"workspace.lite.subscription.weeklyUsage": "週間利用量",
|
||||
"workspace.lite.subscription.monthlyUsage": "月間利用量",
|
||||
"workspace.lite.subscription.resetsIn": "リセットまで",
|
||||
"workspace.lite.subscription.useBalance": "利用限度額に達したら利用可能な残高を使用する",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
"Go モデルを使用するには、opencode の設定で「OpenCode Go」をプロバイダーとして選択してください。",
|
||||
"workspace.lite.other.title": "Goサブスクリプション",
|
||||
"workspace.lite.other.message":
|
||||
"このワークスペースの別のメンバーが既に OpenCode Go を購読しています。ワークスペースにつき1人のメンバーのみが購読できます。",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Goは月額$10のサブスクリプションプランで、人気のオープンコーディングモデルへの安定したアクセスを十分な利用枠で提供します。",
|
||||
"workspace.lite.promo.modelsTitle": "含まれるもの",
|
||||
"workspace.lite.promo.footer":
|
||||
"このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。",
|
||||
"workspace.lite.promo.subscribe": "Goを購読する",
|
||||
"workspace.lite.promo.subscribing": "リダイレクト中...",
|
||||
|
||||
"download.title": "OpenCode | ダウンロード",
|
||||
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
||||
"download.hero.title": "OpenCode をダウンロード",
|
||||
|
||||
@@ -241,6 +241,7 @@ export const dict = {
|
||||
"black.hero.title": "세계 최고의 코딩 모델에 액세스하세요",
|
||||
"black.hero.subtitle": "Claude, GPT, Gemini 등 포함",
|
||||
"black.title": "OpenCode Black | 가격",
|
||||
"black.paused": "Black 플랜 등록이 일시적으로 중단되었습니다.",
|
||||
"black.plan.icon20": "Black 20 플랜",
|
||||
"black.plan.icon100": "Black 100 플랜",
|
||||
"black.plan.icon200": "Black 200 플랜",
|
||||
@@ -342,7 +343,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "캐시 쓰기",
|
||||
"workspace.usage.breakdown.output": "출력",
|
||||
"workspace.usage.breakdown.reasoning": "추론",
|
||||
"workspace.usage.subscription": "구독 (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "비용",
|
||||
"workspace.cost.subtitle": "모델별 사용 비용 내역.",
|
||||
@@ -351,6 +354,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(삭제됨)",
|
||||
"workspace.cost.empty": "선택한 기간에 사용 데이터가 없습니다.",
|
||||
"workspace.cost.subscriptionShort": "구독",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API 키",
|
||||
"workspace.keys.subtitle": "OpenCode 서비스 액세스를 위한 API 키를 관리하세요.",
|
||||
@@ -478,6 +482,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrolled": "등록됨",
|
||||
"workspace.black.waitlist.enrollNote": "등록을 클릭하면 구독이 즉시 시작되며 카드에 요금이 청구됩니다.",
|
||||
|
||||
"workspace.lite.loading": "로드 중...",
|
||||
"workspace.lite.time.day": "일",
|
||||
"workspace.lite.time.days": "일",
|
||||
"workspace.lite.time.hour": "시간",
|
||||
"workspace.lite.time.hours": "시간",
|
||||
"workspace.lite.time.minute": "분",
|
||||
"workspace.lite.time.minutes": "분",
|
||||
"workspace.lite.time.fewSeconds": "몇 초",
|
||||
"workspace.lite.subscription.title": "Go 구독",
|
||||
"workspace.lite.subscription.message": "현재 OpenCode Go를 구독 중입니다.",
|
||||
"workspace.lite.subscription.manage": "구독 관리",
|
||||
"workspace.lite.subscription.rollingUsage": "롤링 사용량",
|
||||
"workspace.lite.subscription.weeklyUsage": "주간 사용량",
|
||||
"workspace.lite.subscription.monthlyUsage": "월간 사용량",
|
||||
"workspace.lite.subscription.resetsIn": "초기화까지 남은 시간:",
|
||||
"workspace.lite.subscription.useBalance": "사용 한도 도달 후에는 보유 잔액 사용",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Go 모델을 사용하려면 opencode 설정에서 "OpenCode Go"를 공급자로 선택하세요.',
|
||||
"workspace.lite.other.title": "Go 구독",
|
||||
"workspace.lite.other.message":
|
||||
"이 워크스페이스의 다른 멤버가 이미 OpenCode Go를 구독 중입니다. 워크스페이스당 한 명의 멤버만 구독할 수 있습니다.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go는 넉넉한 사용 한도와 함께 인기 있는 오픈 코딩 모델에 대한 안정적인 액세스를 제공하는 월 $10의 구독입니다.",
|
||||
"workspace.lite.promo.modelsTitle": "포함 내역",
|
||||
"workspace.lite.promo.footer":
|
||||
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.",
|
||||
"workspace.lite.promo.subscribe": "Go 구독하기",
|
||||
"workspace.lite.promo.subscribing": "리디렉션 중...",
|
||||
|
||||
"download.title": "OpenCode | 다운로드",
|
||||
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
||||
"download.hero.title": "OpenCode 다운로드",
|
||||
|
||||
@@ -245,6 +245,7 @@ export const dict = {
|
||||
"black.hero.title": "Få tilgang til verdens beste kodemodeller",
|
||||
"black.hero.subtitle": "Inkludert Claude, GPT, Gemini og mer",
|
||||
"black.title": "OpenCode Black | Priser",
|
||||
"black.paused": "Black-planregistrering er midlertidig satt på pause.",
|
||||
"black.plan.icon20": "Black 20-plan",
|
||||
"black.plan.icon100": "Black 100-plan",
|
||||
"black.plan.icon200": "Black 200-plan",
|
||||
@@ -346,7 +347,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Cache Skrevet",
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Resonnering",
|
||||
"workspace.usage.subscription": "abonnement (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Kostnad",
|
||||
"workspace.cost.subtitle": "Brukskostnader fordelt på modell.",
|
||||
@@ -355,6 +358,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(slettet)",
|
||||
"workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API-nøkler",
|
||||
"workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.",
|
||||
@@ -483,6 +487,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Når du klikker på Meld på, starter abonnementet umiddelbart og kortet ditt belastes.",
|
||||
|
||||
"workspace.lite.loading": "Laster...",
|
||||
"workspace.lite.time.day": "dag",
|
||||
"workspace.lite.time.days": "dager",
|
||||
"workspace.lite.time.hour": "time",
|
||||
"workspace.lite.time.hours": "timer",
|
||||
"workspace.lite.time.minute": "minutt",
|
||||
"workspace.lite.time.minutes": "minutter",
|
||||
"workspace.lite.time.fewSeconds": "noen få sekunder",
|
||||
"workspace.lite.subscription.title": "Go-abonnement",
|
||||
"workspace.lite.subscription.message": "Du abonnerer på OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "Administrer abonnement",
|
||||
"workspace.lite.subscription.rollingUsage": "Løpende bruk",
|
||||
"workspace.lite.subscription.weeklyUsage": "Ukentlig bruk",
|
||||
"workspace.lite.subscription.monthlyUsage": "Månedlig bruk",
|
||||
"workspace.lite.subscription.resetsIn": "Nullstilles om",
|
||||
"workspace.lite.subscription.useBalance": "Bruk din tilgjengelige saldo etter å ha nådd bruksgrensene",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Velg "OpenCode Go" som leverandør i opencode-konfigurasjonen din for å bruke Go-modeller.',
|
||||
"workspace.lite.other.title": "Go-abonnement",
|
||||
"workspace.lite.other.message":
|
||||
"Et annet medlem i dette arbeidsområdet abonnerer allerede på OpenCode Go. Kun ett medlem per arbeidsområde kan abonnere.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go er et abonnement til $10 per måned som gir pålitelig tilgang til populære åpne kodemodeller med rause bruksgrenser.",
|
||||
"workspace.lite.promo.modelsTitle": "Hva som er inkludert",
|
||||
"workspace.lite.promo.footer":
|
||||
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.",
|
||||
"workspace.lite.promo.subscribe": "Abonner på Go",
|
||||
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
||||
|
||||
"download.title": "OpenCode | Last ned",
|
||||
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
||||
"download.hero.title": "Last ned OpenCode",
|
||||
|
||||
@@ -246,6 +246,7 @@ export const dict = {
|
||||
"black.hero.title": "Dostęp do najlepszych na świecie modeli kodujących",
|
||||
"black.hero.subtitle": "W tym Claude, GPT, Gemini i inne",
|
||||
"black.title": "OpenCode Black | Cennik",
|
||||
"black.paused": "Rejestracja planu Black jest tymczasowo wstrzymana.",
|
||||
"black.plan.icon20": "Plan Black 20",
|
||||
"black.plan.icon100": "Plan Black 100",
|
||||
"black.plan.icon200": "Plan Black 200",
|
||||
@@ -347,7 +348,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Zapis Cache",
|
||||
"workspace.usage.breakdown.output": "Wyjście",
|
||||
"workspace.usage.breakdown.reasoning": "Rozumowanie",
|
||||
"workspace.usage.subscription": "subskrypcja (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Koszt",
|
||||
"workspace.cost.subtitle": "Koszty użycia w podziale na modele.",
|
||||
@@ -356,6 +359,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(usunięte)",
|
||||
"workspace.cost.empty": "Brak danych o użyciu dla wybranego okresu.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Klucze API",
|
||||
"workspace.keys.subtitle": "Zarządzaj kluczami API do usług opencode.",
|
||||
@@ -484,6 +488,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Po kliknięciu Zapisz się, Twoja subskrypcja rozpocznie się natychmiast, a karta zostanie obciążona.",
|
||||
|
||||
"workspace.lite.loading": "Ładowanie...",
|
||||
"workspace.lite.time.day": "dzień",
|
||||
"workspace.lite.time.days": "dni",
|
||||
"workspace.lite.time.hour": "godzina",
|
||||
"workspace.lite.time.hours": "godzin(y)",
|
||||
"workspace.lite.time.minute": "minuta",
|
||||
"workspace.lite.time.minutes": "minut(y)",
|
||||
"workspace.lite.time.fewSeconds": "kilka sekund",
|
||||
"workspace.lite.subscription.title": "Subskrypcja Go",
|
||||
"workspace.lite.subscription.message": "Subskrybujesz OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "Zarządzaj subskrypcją",
|
||||
"workspace.lite.subscription.rollingUsage": "Użycie kroczące",
|
||||
"workspace.lite.subscription.weeklyUsage": "Użycie tygodniowe",
|
||||
"workspace.lite.subscription.monthlyUsage": "Użycie miesięczne",
|
||||
"workspace.lite.subscription.resetsIn": "Resetuje się za",
|
||||
"workspace.lite.subscription.useBalance": "Użyj dostępnego salda po osiągnięciu limitów użycia",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Wybierz "OpenCode Go" jako dostawcę w konfiguracji opencode, aby używać modeli Go.',
|
||||
"workspace.lite.other.title": "Subskrypcja Go",
|
||||
"workspace.lite.other.message":
|
||||
"Inny członek tego obszaru roboczego już subskrybuje OpenCode Go. Tylko jeden członek na obszar roboczy może subskrybować.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go to subskrypcja za $10 miesięcznie, która zapewnia niezawodny dostęp do popularnych otwartych modeli do kodowania z hojnymi limitami użycia.",
|
||||
"workspace.lite.promo.modelsTitle": "Co zawiera",
|
||||
"workspace.lite.promo.footer":
|
||||
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.",
|
||||
"workspace.lite.promo.subscribe": "Subskrybuj Go",
|
||||
"workspace.lite.promo.subscribing": "Przekierowywanie...",
|
||||
|
||||
"download.title": "OpenCode | Pobierz",
|
||||
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
||||
"download.hero.title": "Pobierz OpenCode",
|
||||
|
||||
@@ -249,6 +249,7 @@ export const dict = {
|
||||
"black.hero.title": "Доступ к лучшим моделям для кодинга в мире",
|
||||
"black.hero.subtitle": "Включая Claude, GPT, Gemini и другие",
|
||||
"black.title": "OpenCode Black | Цены",
|
||||
"black.paused": "Регистрация на план Black временно приостановлена.",
|
||||
"black.plan.icon20": "План Black 20",
|
||||
"black.plan.icon100": "План Black 100",
|
||||
"black.plan.icon200": "План Black 200",
|
||||
@@ -352,7 +353,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Запись кэша",
|
||||
"workspace.usage.breakdown.output": "Выход",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning (рассуждения)",
|
||||
"workspace.usage.subscription": "подписка (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Расходы",
|
||||
"workspace.cost.subtitle": "Расходы на использование с разбивкой по моделям.",
|
||||
@@ -361,6 +364,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(удалено)",
|
||||
"workspace.cost.empty": "Нет данных об использовании за выбранный период.",
|
||||
"workspace.cost.subscriptionShort": "подписка",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Ключи",
|
||||
"workspace.keys.subtitle": "Управляйте вашими API ключами для доступа к сервисам opencode.",
|
||||
@@ -489,6 +493,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Когда вы нажмете Подключиться, ваша подписка начнется немедленно, и с карты будет списана оплата.",
|
||||
|
||||
"workspace.lite.loading": "Загрузка...",
|
||||
"workspace.lite.time.day": "день",
|
||||
"workspace.lite.time.days": "дней",
|
||||
"workspace.lite.time.hour": "час",
|
||||
"workspace.lite.time.hours": "часов",
|
||||
"workspace.lite.time.minute": "минута",
|
||||
"workspace.lite.time.minutes": "минут",
|
||||
"workspace.lite.time.fewSeconds": "несколько секунд",
|
||||
"workspace.lite.subscription.title": "Подписка Go",
|
||||
"workspace.lite.subscription.message": "Вы подписаны на OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "Управление подпиской",
|
||||
"workspace.lite.subscription.rollingUsage": "Скользящее использование",
|
||||
"workspace.lite.subscription.weeklyUsage": "Недельное использование",
|
||||
"workspace.lite.subscription.monthlyUsage": "Ежемесячное использование",
|
||||
"workspace.lite.subscription.resetsIn": "Сброс через",
|
||||
"workspace.lite.subscription.useBalance": "Использовать доступный баланс после достижения лимитов",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Выберите "OpenCode Go" в качестве провайдера в настройках opencode для использования моделей Go.',
|
||||
"workspace.lite.other.title": "Подписка Go",
|
||||
"workspace.lite.other.message":
|
||||
"Другой участник в этом рабочем пространстве уже подписан на OpenCode Go. Только один участник в рабочем пространстве может оформить подписку.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go — это подписка за $10 в месяц, которая предоставляет надежный доступ к популярным открытым моделям для кодинга с щедрыми лимитами использования.",
|
||||
"workspace.lite.promo.modelsTitle": "Что включено",
|
||||
"workspace.lite.promo.footer":
|
||||
"План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.",
|
||||
"workspace.lite.promo.subscribe": "Подписаться на Go",
|
||||
"workspace.lite.promo.subscribing": "Перенаправление...",
|
||||
|
||||
"download.title": "OpenCode | Скачать",
|
||||
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
||||
"download.hero.title": "Скачать OpenCode",
|
||||
|
||||
@@ -244,6 +244,7 @@ export const dict = {
|
||||
"black.hero.title": "เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
|
||||
"black.hero.subtitle": "รวมถึง Claude, GPT, Gemini และอื่นๆ อีกมากมาย",
|
||||
"black.title": "OpenCode Black | ราคา",
|
||||
"black.paused": "การสมัครแผน Black หยุดชั่วคราว",
|
||||
"black.plan.icon20": "แผน Black 20",
|
||||
"black.plan.icon100": "แผน Black 100",
|
||||
"black.plan.icon200": "แผน Black 200",
|
||||
@@ -345,7 +346,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Cache Write",
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning",
|
||||
"workspace.usage.subscription": "สมัครสมาชิก (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "ค่าใช้จ่าย",
|
||||
"workspace.cost.subtitle": "ต้นทุนการใช้งานแยกตามโมเดล",
|
||||
@@ -354,6 +357,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(ลบแล้ว)",
|
||||
"workspace.cost.empty": "ไม่มีข้อมูลการใช้งานในช่วงเวลาที่เลือก",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "จัดการ API keys ของคุณสำหรับการเข้าถึงบริการ OpenCode",
|
||||
@@ -482,6 +486,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"เมื่อคุณคลิกลงทะเบียน การสมัครสมาชิกของคุณจะเริ่มต้นทันทีและบัตรของคุณจะถูกเรียกเก็บเงิน",
|
||||
|
||||
"workspace.lite.loading": "กำลังโหลด...",
|
||||
"workspace.lite.time.day": "วัน",
|
||||
"workspace.lite.time.days": "วัน",
|
||||
"workspace.lite.time.hour": "ชั่วโมง",
|
||||
"workspace.lite.time.hours": "ชั่วโมง",
|
||||
"workspace.lite.time.minute": "นาที",
|
||||
"workspace.lite.time.minutes": "นาที",
|
||||
"workspace.lite.time.fewSeconds": "ไม่กี่วินาที",
|
||||
"workspace.lite.subscription.title": "การสมัครสมาชิก Go",
|
||||
"workspace.lite.subscription.message": "คุณได้สมัครสมาชิก OpenCode Go แล้ว",
|
||||
"workspace.lite.subscription.manage": "จัดการการสมัครสมาชิก",
|
||||
"workspace.lite.subscription.rollingUsage": "การใช้งานแบบหมุนเวียน",
|
||||
"workspace.lite.subscription.weeklyUsage": "การใช้งานรายสัปดาห์",
|
||||
"workspace.lite.subscription.monthlyUsage": "การใช้งานรายเดือน",
|
||||
"workspace.lite.subscription.resetsIn": "รีเซ็ตใน",
|
||||
"workspace.lite.subscription.useBalance": "ใช้ยอดคงเหลือของคุณหลังจากถึงขีดจำกัดการใช้งาน",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'เลือก "OpenCode Go" เป็นผู้ให้บริการในการตั้งค่า opencode ของคุณเพื่อใช้โมเดล Go',
|
||||
"workspace.lite.other.title": "การสมัครสมาชิก Go",
|
||||
"workspace.lite.other.message":
|
||||
"สมาชิกคนอื่นใน Workspace นี้ได้สมัคร OpenCode Go แล้ว สามารถสมัครได้เพียงหนึ่งคนต่อหนึ่ง Workspace เท่านั้น",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go เป็นการสมัครสมาชิกราคา 10 ดอลลาร์ต่อเดือน ที่ให้การเข้าถึงโมเดลโอเพนโค้ดดิงยอดนิยมได้อย่างเสถียร ด้วยขีดจำกัดการใช้งานที่ครอบคลุม",
|
||||
"workspace.lite.promo.modelsTitle": "สิ่งที่รวมอยู่ด้วย",
|
||||
"workspace.lite.promo.footer":
|
||||
"แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ",
|
||||
"workspace.lite.promo.subscribe": "สมัครสมาชิก Go",
|
||||
"workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...",
|
||||
|
||||
"download.title": "OpenCode | ดาวน์โหลด",
|
||||
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
||||
"download.hero.title": "ดาวน์โหลด OpenCode",
|
||||
|
||||
@@ -247,6 +247,7 @@ export const dict = {
|
||||
"black.hero.title": "Dünyanın en iyi kodlama modellerine erişin",
|
||||
"black.hero.subtitle": "Claude, GPT, Gemini ve daha fazlası dahil",
|
||||
"black.title": "OpenCode Black | Fiyatlandırma",
|
||||
"black.paused": "Black plan kaydı geçici olarak duraklatıldı.",
|
||||
"black.plan.icon20": "Black 20 planı",
|
||||
"black.plan.icon100": "Black 100 planı",
|
||||
"black.plan.icon200": "Black 200 planı",
|
||||
@@ -348,7 +349,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "Önbellek Yazma",
|
||||
"workspace.usage.breakdown.output": "Çıkış",
|
||||
"workspace.usage.breakdown.reasoning": "Muhakeme",
|
||||
"workspace.usage.subscription": "abonelik (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Maliyet",
|
||||
"workspace.cost.subtitle": "Modele göre ayrılmış kullanım maliyetleri.",
|
||||
@@ -357,6 +360,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(silindi)",
|
||||
"workspace.cost.empty": "Seçilen döneme ait kullanım verisi yok.",
|
||||
"workspace.cost.subscriptionShort": "abonelik",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Anahtarları",
|
||||
"workspace.keys.subtitle": "opencode hizmetlerine erişim için API anahtarlarınızı yönetin.",
|
||||
@@ -485,6 +489,36 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Kayıt Ol'a tıkladığınızda aboneliğiniz hemen başlar ve kartınızdan çekim yapılır.",
|
||||
|
||||
"workspace.lite.loading": "Yükleniyor...",
|
||||
"workspace.lite.time.day": "gün",
|
||||
"workspace.lite.time.days": "gün",
|
||||
"workspace.lite.time.hour": "saat",
|
||||
"workspace.lite.time.hours": "saat",
|
||||
"workspace.lite.time.minute": "dakika",
|
||||
"workspace.lite.time.minutes": "dakika",
|
||||
"workspace.lite.time.fewSeconds": "birkaç saniye",
|
||||
"workspace.lite.subscription.title": "Go Aboneliği",
|
||||
"workspace.lite.subscription.message": "OpenCode Go abonesisiniz.",
|
||||
"workspace.lite.subscription.manage": "Aboneliği Yönet",
|
||||
"workspace.lite.subscription.rollingUsage": "Devam Eden Kullanım",
|
||||
"workspace.lite.subscription.weeklyUsage": "Haftalık Kullanım",
|
||||
"workspace.lite.subscription.monthlyUsage": "Aylık Kullanım",
|
||||
"workspace.lite.subscription.resetsIn": "Sıfırlama süresi",
|
||||
"workspace.lite.subscription.useBalance": "Kullanım limitlerine ulaştıktan sonra mevcut bakiyenizi kullanın",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
'Go modellerini kullanmak için opencode yapılandırmanızda "OpenCode Go"\'yu sağlayıcı olarak seçin.',
|
||||
"workspace.lite.other.title": "Go Aboneliği",
|
||||
"workspace.lite.other.message":
|
||||
"Bu çalışma alanındaki başka bir üye zaten OpenCode Go abonesi. Çalışma alanı başına yalnızca bir üye abone olabilir.",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go, cömert kullanım limitleriyle popüler açık kodlama modellerine güvenilir erişim sağlayan aylık 10$'lık bir aboneliktir.",
|
||||
"workspace.lite.promo.modelsTitle": "Neler Dahil",
|
||||
"workspace.lite.promo.footer":
|
||||
"Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.",
|
||||
"workspace.lite.promo.subscribe": "Go'ya Abone Ol",
|
||||
"workspace.lite.promo.subscribing": "Yönlendiriliyor...",
|
||||
|
||||
"download.title": "OpenCode | İndir",
|
||||
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
||||
"download.hero.title": "OpenCode'u İndir",
|
||||
|
||||
@@ -234,6 +234,7 @@ export const dict = {
|
||||
"black.hero.title": "访问全球顶尖编程模型",
|
||||
"black.hero.subtitle": "包括 Claude, GPT, Gemini 等",
|
||||
"black.title": "OpenCode Black | 定价",
|
||||
"black.paused": "Black 订阅已暂时暂停注册。",
|
||||
"black.plan.icon20": "Black 20 计划",
|
||||
"black.plan.icon100": "Black 100 计划",
|
||||
"black.plan.icon200": "Black 200 计划",
|
||||
@@ -333,7 +334,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "缓存写入",
|
||||
"workspace.usage.breakdown.output": "输出",
|
||||
"workspace.usage.breakdown.reasoning": "推理",
|
||||
"workspace.usage.subscription": "订阅 (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "成本",
|
||||
"workspace.cost.subtitle": "按模型细分的使用成本。",
|
||||
@@ -342,6 +345,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(已删除)",
|
||||
"workspace.cost.empty": "所选期间无可用使用数据。",
|
||||
"workspace.cost.subscriptionShort": "订阅",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API 密钥",
|
||||
"workspace.keys.subtitle": "管理访问 OpenCode 服务的 API 密钥。",
|
||||
@@ -469,6 +473,35 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrolled": "已加入",
|
||||
"workspace.black.waitlist.enrollNote": "点击加入后,您的订阅将立即开始,并将从您的卡中扣费。",
|
||||
|
||||
"workspace.lite.loading": "加载中...",
|
||||
"workspace.lite.time.day": "天",
|
||||
"workspace.lite.time.days": "天",
|
||||
"workspace.lite.time.hour": "小时",
|
||||
"workspace.lite.time.hours": "小时",
|
||||
"workspace.lite.time.minute": "分钟",
|
||||
"workspace.lite.time.minutes": "分钟",
|
||||
"workspace.lite.time.fewSeconds": "几秒钟",
|
||||
"workspace.lite.subscription.title": "Go 订阅",
|
||||
"workspace.lite.subscription.message": "您已订阅 OpenCode Go。",
|
||||
"workspace.lite.subscription.manage": "管理订阅",
|
||||
"workspace.lite.subscription.rollingUsage": "滚动用量",
|
||||
"workspace.lite.subscription.weeklyUsage": "每周用量",
|
||||
"workspace.lite.subscription.monthlyUsage": "每月用量",
|
||||
"workspace.lite.subscription.resetsIn": "重置于",
|
||||
"workspace.lite.subscription.useBalance": "达到使用限额后使用您的可用余额",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
"在你的 opencode 配置中选择「OpenCode Go」作为提供商,即可使用 Go 模型。",
|
||||
"workspace.lite.other.title": "Go 订阅",
|
||||
"workspace.lite.other.message": "此工作区中的另一位成员已经订阅了 OpenCode Go。每个工作区只有一名成员可以订阅。",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go 是一个每月 $10 的订阅计划,提供对主流开源编码模型的稳定访问,并配备充足的使用额度。",
|
||||
"workspace.lite.promo.modelsTitle": "包含模型",
|
||||
"workspace.lite.promo.footer":
|
||||
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。",
|
||||
"workspace.lite.promo.subscribe": "订阅 Go",
|
||||
"workspace.lite.promo.subscribing": "正在重定向...",
|
||||
|
||||
"download.title": "OpenCode | 下载",
|
||||
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
|
||||
"download.hero.title": "下载 OpenCode",
|
||||
|
||||
@@ -234,6 +234,7 @@ export const dict = {
|
||||
"black.hero.title": "存取全球最佳編碼模型",
|
||||
"black.hero.subtitle": "包括 Claude、GPT、Gemini 等",
|
||||
"black.title": "OpenCode Black | 定價",
|
||||
"black.paused": "Black 訂閱暫時暫停註冊。",
|
||||
"black.plan.icon20": "Black 20 方案",
|
||||
"black.plan.icon100": "Black 100 方案",
|
||||
"black.plan.icon200": "Black 200 方案",
|
||||
@@ -333,7 +334,9 @@ export const dict = {
|
||||
"workspace.usage.breakdown.cacheWrite": "快取寫入",
|
||||
"workspace.usage.breakdown.output": "輸出",
|
||||
"workspace.usage.breakdown.reasoning": "推理",
|
||||
"workspace.usage.subscription": "訂閱 (${{amount}})",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "成本",
|
||||
"workspace.cost.subtitle": "按模型細分的使用成本。",
|
||||
@@ -342,6 +345,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(已刪除)",
|
||||
"workspace.cost.empty": "所選期間沒有可用的使用資料。",
|
||||
"workspace.cost.subscriptionShort": "訂",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API 金鑰",
|
||||
"workspace.keys.subtitle": "管理你的 API 金鑰以存取 OpenCode 服務。",
|
||||
@@ -469,6 +473,35 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrolled": "已加入",
|
||||
"workspace.black.waitlist.enrollNote": "當你點選「加入」後,你的訂閱將立即開始,並且將從你的卡片中扣款。",
|
||||
|
||||
"workspace.lite.loading": "載入中...",
|
||||
"workspace.lite.time.day": "天",
|
||||
"workspace.lite.time.days": "天",
|
||||
"workspace.lite.time.hour": "小時",
|
||||
"workspace.lite.time.hours": "小時",
|
||||
"workspace.lite.time.minute": "分鐘",
|
||||
"workspace.lite.time.minutes": "分鐘",
|
||||
"workspace.lite.time.fewSeconds": "幾秒",
|
||||
"workspace.lite.subscription.title": "Go 訂閱",
|
||||
"workspace.lite.subscription.message": "您已訂閱 OpenCode Go。",
|
||||
"workspace.lite.subscription.manage": "管理訂閱",
|
||||
"workspace.lite.subscription.rollingUsage": "滾動使用量",
|
||||
"workspace.lite.subscription.weeklyUsage": "每週使用量",
|
||||
"workspace.lite.subscription.monthlyUsage": "每月使用量",
|
||||
"workspace.lite.subscription.resetsIn": "重置時間:",
|
||||
"workspace.lite.subscription.useBalance": "達到使用限制後使用您的可用餘額",
|
||||
"workspace.lite.subscription.selectProvider":
|
||||
"在您的 opencode 設定中選擇「OpenCode Go」作為提供商,即可使用 Go 模型。",
|
||||
"workspace.lite.other.title": "Go 訂閱",
|
||||
"workspace.lite.other.message": "此工作區中的另一位成員已訂閱 OpenCode Go。每個工作區只能有一位成員訂閱。",
|
||||
"workspace.lite.promo.title": "OpenCode Go",
|
||||
"workspace.lite.promo.description":
|
||||
"OpenCode Go 是一個每月 $10 的訂閱方案,提供對主流開放原始碼編碼模型的穩定存取,並配備充足的使用額度。",
|
||||
"workspace.lite.promo.modelsTitle": "包含模型",
|
||||
"workspace.lite.promo.footer":
|
||||
"該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。",
|
||||
"workspace.lite.promo.subscribe": "訂閱 Go",
|
||||
"workspace.lite.promo.subscribing": "重新導向中...",
|
||||
|
||||
"download.title": "OpenCode | 下載",
|
||||
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
|
||||
"download.hero.title": "下載 OpenCode",
|
||||
|
||||
@@ -335,6 +335,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="paused"] {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
padding: 120px 20px;
|
||||
}
|
||||
|
||||
[data-slot="pricing-card"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { PlanIcon, plans } from "./common"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
|
||||
const paused = true
|
||||
|
||||
export default function Black() {
|
||||
const [params] = useSearchParams()
|
||||
const i18n = useI18n()
|
||||
@@ -42,72 +44,76 @@ export default function Black() {
|
||||
<>
|
||||
<Title>{i18n.t("black.title")}</Title>
|
||||
<section data-slot="cta">
|
||||
<Switch>
|
||||
<Match when={!selected()}>
|
||||
<div data-slot="pricing">
|
||||
<For each={plans}>
|
||||
{(plan) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(plan.id)}
|
||||
data-slot="pricing-card"
|
||||
style={{ "view-transition-name": `card-${plan.id}` }}
|
||||
>
|
||||
<Show when={!paused} fallback={<p data-slot="paused">{i18n.t("black.paused")}</p>}>
|
||||
<Switch>
|
||||
<Match when={!selected()}>
|
||||
<div data-slot="pricing">
|
||||
<For each={plans}>
|
||||
{(plan) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(plan.id)}
|
||||
data-slot="pricing-card"
|
||||
style={{ "view-transition-name": `card-${plan.id}` }}
|
||||
>
|
||||
<div data-slot="icon">
|
||||
<PlanIcon plan={plan.id} />
|
||||
</div>
|
||||
<p data-slot="price">
|
||||
<span data-slot="amount">${plan.id}</span>{" "}
|
||||
<span data-slot="period">{i18n.t("black.price.perMonth")}</span>
|
||||
<Show when={plan.multiplier}>
|
||||
{(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
|
||||
</Show>
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={selectedPlan()}>
|
||||
{(plan) => (
|
||||
<div data-slot="selected-plan">
|
||||
<div data-slot="selected-card" style={{ "view-transition-name": `card-${plan().id}` }}>
|
||||
<div data-slot="icon">
|
||||
<PlanIcon plan={plan.id} />
|
||||
<PlanIcon plan={plan().id} />
|
||||
</div>
|
||||
<p data-slot="price">
|
||||
<span data-slot="amount">${plan.id}</span>{" "}
|
||||
<span data-slot="period">{i18n.t("black.price.perMonth")}</span>
|
||||
<Show when={plan.multiplier}>
|
||||
<span data-slot="amount">${plan().id}</span>{" "}
|
||||
<span data-slot="period">{i18n.t("black.price.perPersonBilledMonthly")}</span>
|
||||
<Show when={plan().multiplier}>
|
||||
{(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
|
||||
</Show>
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={selectedPlan()}>
|
||||
{(plan) => (
|
||||
<div data-slot="selected-plan">
|
||||
<div data-slot="selected-card" style={{ "view-transition-name": `card-${plan().id}` }}>
|
||||
<div data-slot="icon">
|
||||
<PlanIcon plan={plan().id} />
|
||||
</div>
|
||||
<p data-slot="price">
|
||||
<span data-slot="amount">${plan().id}</span>{" "}
|
||||
<span data-slot="period">{i18n.t("black.price.perPersonBilledMonthly")}</span>
|
||||
<Show when={plan().multiplier}>
|
||||
{(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
|
||||
</Show>
|
||||
</p>
|
||||
<ul data-slot="terms" style={{ "view-transition-name": `terms-${plan().id}` }}>
|
||||
<li>{i18n.t("black.terms.1")}</li>
|
||||
<li>{i18n.t("black.terms.2")}</li>
|
||||
<li>{i18n.t("black.terms.3")}</li>
|
||||
<li>{i18n.t("black.terms.4")}</li>
|
||||
<li>{i18n.t("black.terms.5")}</li>
|
||||
<li>{i18n.t("black.terms.6")}</li>
|
||||
<li>{i18n.t("black.terms.7")}</li>
|
||||
</ul>
|
||||
<div data-slot="actions" style={{ "view-transition-name": `actions-${plan().id}` }}>
|
||||
<button type="button" onClick={() => cancel()} data-slot="cancel">
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
|
||||
{i18n.t("black.action.continue")}
|
||||
</a>
|
||||
<ul data-slot="terms" style={{ "view-transition-name": `terms-${plan().id}` }}>
|
||||
<li>{i18n.t("black.terms.1")}</li>
|
||||
<li>{i18n.t("black.terms.2")}</li>
|
||||
<li>{i18n.t("black.terms.3")}</li>
|
||||
<li>{i18n.t("black.terms.4")}</li>
|
||||
<li>{i18n.t("black.terms.5")}</li>
|
||||
<li>{i18n.t("black.terms.6")}</li>
|
||||
<li>{i18n.t("black.terms.7")}</li>
|
||||
</ul>
|
||||
<div data-slot="actions" style={{ "view-transition-name": `actions-${plan().id}` }}>
|
||||
<button type="button" onClick={() => cancel()} data-slot="cancel">
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
|
||||
{i18n.t("black.action.continue")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
<p data-slot="fine-print" style={{ "view-transition-name": "fine-print" }}>
|
||||
{i18n.t("black.finePrint.beforeTerms")} ·{" "}
|
||||
<A href={language.route("/legal/terms-of-service")}>{i18n.t("black.finePrint.terms")}</A>
|
||||
</p>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
<Show when={!paused}>
|
||||
<p data-slot="fine-print" style={{ "view-transition-name": "fine-print" }}>
|
||||
{i18n.t("black.finePrint.beforeTerms")} ·{" "}
|
||||
<A href={language.route("/legal/terms-of-service")}>{i18n.t("black.finePrint.terms")}</A>
|
||||
</p>
|
||||
</Show>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, LiteTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
|
||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||
import { BlackData } from "@opencode-ai/console-core/black.js"
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||
@@ -103,310 +103,93 @@ export async function POST(input: APIEvent) {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
|
||||
const workspaceID = body.data.object.custom_fields.find((f) => f.key === "workspaceid")?.text?.value
|
||||
const amountInCents = body.data.object.amount_total as number
|
||||
const customerID = body.data.object.customer as string
|
||||
const customerEmail = body.data.object.customer_details?.email as string
|
||||
const invoiceID = body.data.object.invoice as string
|
||||
const subscriptionID = body.data.object.subscription as string
|
||||
const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
|
||||
if (body.type === "customer.subscription.created") {
|
||||
const type = body.data.object.metadata?.type
|
||||
if (type === "lite") {
|
||||
const workspaceID = body.data.object.metadata?.workspaceID
|
||||
const userID = body.data.object.metadata?.userID
|
||||
const customerID = body.data.object.customer as string
|
||||
const invoiceID = body.data.object.latest_invoice as string
|
||||
const subscriptionID = body.data.object.id as string
|
||||
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!amountInCents) throw new Error("Amount not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!userID) throw new Error("User ID not found")
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
// get payment id from invoice
|
||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||
expand: ["payments"],
|
||||
})
|
||||
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||
if (!paymentID) throw new Error("Payment ID not found")
|
||||
// get payment id from invoice
|
||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||
expand: ["payments"],
|
||||
})
|
||||
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||
if (!paymentID) throw new Error("Payment ID not found")
|
||||
|
||||
// get payment method for the payment intent
|
||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||
expand: ["payment_method"],
|
||||
})
|
||||
const paymentMethod = paymentIntent.payment_method
|
||||
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||
// get payment method for the payment intent
|
||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||
expand: ["payment_method"],
|
||||
})
|
||||
const paymentMethod = paymentIntent.payment_method
|
||||
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||
|
||||
// get coupon id from promotion code
|
||||
const couponID = await (async () => {
|
||||
if (!promoCode) return
|
||||
const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
|
||||
const couponID = coupon.coupon.id
|
||||
if (!couponID) throw new Error("Coupon not found for promotion code")
|
||||
return couponID
|
||||
})()
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
// look up current billing
|
||||
const billing = await Billing.get()
|
||||
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
|
||||
if (billing.customerID && billing.customerID !== customerID) throw new Error("Customer ID mismatch")
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
// look up current billing
|
||||
const billing = await Billing.get()
|
||||
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
|
||||
|
||||
// Temporarily skip this check because during Black drop, user can checkout
|
||||
// as a new customer
|
||||
//if (billing.customerID !== customerID) throw new Error("Customer ID mismatch")
|
||||
|
||||
// Temporarily check the user to apply to. After Black drop, we will allow
|
||||
// look up the user to apply to
|
||||
const users = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ id: UserTable.id, email: AuthTable.subject })
|
||||
.from(UserTable)
|
||||
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||
)
|
||||
const user = users.find((u) => u.email === customerEmail) ?? users[0]
|
||||
if (!user) {
|
||||
console.error(`Error: User with email ${customerEmail} not found in workspace ${workspaceID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// set customer metadata
|
||||
if (!billing?.customerID) {
|
||||
await Billing.stripe().customers.update(customerID, {
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
subscriptionID,
|
||||
subscription: {
|
||||
status: "subscribed",
|
||||
coupon: couponID,
|
||||
seats: 1,
|
||||
plan: "200",
|
||||
// set customer metadata
|
||||
if (!billing?.customerID) {
|
||||
await Billing.stripe().customers.update(customerID, {
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
paymentMethodID: paymentMethod.id,
|
||||
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||
paymentMethodType: paymentMethod.type,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
}
|
||||
|
||||
await tx.insert(SubscriptionTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("subscription"),
|
||||
userID: user.id,
|
||||
})
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
liteSubscriptionID: subscriptionID,
|
||||
lite: {},
|
||||
paymentMethodID: paymentMethod.id,
|
||||
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||
paymentMethodType: paymentMethod.type,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(amountInCents),
|
||||
paymentID,
|
||||
invoiceID,
|
||||
customerID,
|
||||
enrichment: {
|
||||
type: "subscription",
|
||||
couponID,
|
||||
},
|
||||
await tx.insert(LiteTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("lite"),
|
||||
userID: userID,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
if (body.type === "customer.subscription.created") {
|
||||
/*
|
||||
{
|
||||
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
|
||||
object: "event",
|
||||
api_version: "2025-07-30.basil",
|
||||
created: 1767766916,
|
||||
data: {
|
||||
object: {
|
||||
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||
object: "subscription",
|
||||
application: null,
|
||||
application_fee_percent: null,
|
||||
automatic_tax: {
|
||||
disabled_reason: null,
|
||||
enabled: false,
|
||||
liability: null,
|
||||
},
|
||||
billing_cycle_anchor: 1770445200,
|
||||
billing_cycle_anchor_config: null,
|
||||
billing_mode: {
|
||||
flexible: {
|
||||
proration_discounts: "included",
|
||||
},
|
||||
type: "flexible",
|
||||
updated_at: 1770445200,
|
||||
},
|
||||
billing_thresholds: null,
|
||||
cancel_at: null,
|
||||
cancel_at_period_end: false,
|
||||
canceled_at: null,
|
||||
cancellation_details: {
|
||||
comment: null,
|
||||
feedback: null,
|
||||
reason: null,
|
||||
},
|
||||
collection_method: "charge_automatically",
|
||||
created: 1770445200,
|
||||
currency: "usd",
|
||||
customer: "cus_TkKmZZvysJ2wej",
|
||||
customer_account: null,
|
||||
days_until_due: null,
|
||||
default_payment_method: null,
|
||||
default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
|
||||
default_tax_rates: [],
|
||||
description: null,
|
||||
discounts: [],
|
||||
ended_at: null,
|
||||
invoice_settings: {
|
||||
account_tax_ids: null,
|
||||
issuer: {
|
||||
type: "self",
|
||||
},
|
||||
},
|
||||
items: {
|
||||
object: "list",
|
||||
data: [
|
||||
{
|
||||
id: "si_TkKnBKXFX76t0O",
|
||||
object: "subscription_item",
|
||||
billing_thresholds: null,
|
||||
created: 1770445200,
|
||||
current_period_end: 1772864400,
|
||||
current_period_start: 1770445200,
|
||||
discounts: [],
|
||||
metadata: {},
|
||||
plan: {
|
||||
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||
object: "plan",
|
||||
active: true,
|
||||
amount: 20000,
|
||||
amount_decimal: "20000",
|
||||
billing_scheme: "per_unit",
|
||||
created: 1767725082,
|
||||
currency: "usd",
|
||||
interval: "month",
|
||||
interval_count: 1,
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
meter: null,
|
||||
nickname: null,
|
||||
product: "prod_Tk9LjWT1n0DgYm",
|
||||
tiers_mode: null,
|
||||
transform_usage: null,
|
||||
trial_period_days: null,
|
||||
usage_type: "licensed",
|
||||
},
|
||||
price: {
|
||||
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||
object: "price",
|
||||
active: true,
|
||||
billing_scheme: "per_unit",
|
||||
created: 1767725082,
|
||||
currency: "usd",
|
||||
custom_unit_amount: null,
|
||||
livemode: false,
|
||||
lookup_key: null,
|
||||
metadata: {},
|
||||
nickname: null,
|
||||
product: "prod_Tk9LjWT1n0DgYm",
|
||||
recurring: {
|
||||
interval: "month",
|
||||
interval_count: 1,
|
||||
meter: null,
|
||||
trial_period_days: null,
|
||||
usage_type: "licensed",
|
||||
},
|
||||
tax_behavior: "unspecified",
|
||||
tiers_mode: null,
|
||||
transform_quantity: null,
|
||||
type: "recurring",
|
||||
unit_amount: 20000,
|
||||
unit_amount_decimal: "20000",
|
||||
},
|
||||
quantity: 1,
|
||||
subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||
tax_rates: [],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
total_count: 1,
|
||||
url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||
},
|
||||
latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
next_pending_invoice_item_invoice: null,
|
||||
on_behalf_of: null,
|
||||
pause_collection: null,
|
||||
payment_settings: {
|
||||
payment_method_options: null,
|
||||
payment_method_types: null,
|
||||
save_default_payment_method: "off",
|
||||
},
|
||||
pending_invoice_item_interval: null,
|
||||
pending_setup_intent: null,
|
||||
pending_update: null,
|
||||
plan: {
|
||||
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||
object: "plan",
|
||||
active: true,
|
||||
amount: 20000,
|
||||
amount_decimal: "20000",
|
||||
billing_scheme: "per_unit",
|
||||
created: 1767725082,
|
||||
currency: "usd",
|
||||
interval: "month",
|
||||
interval_count: 1,
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
meter: null,
|
||||
nickname: null,
|
||||
product: "prod_Tk9LjWT1n0DgYm",
|
||||
tiers_mode: null,
|
||||
transform_usage: null,
|
||||
trial_period_days: null,
|
||||
usage_type: "licensed",
|
||||
},
|
||||
quantity: 1,
|
||||
schedule: null,
|
||||
start_date: 1770445200,
|
||||
status: "active",
|
||||
test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
|
||||
transfer_data: null,
|
||||
trial_end: null,
|
||||
trial_settings: {
|
||||
end_behavior: {
|
||||
missing_payment_method: "create_invoice",
|
||||
},
|
||||
},
|
||||
trial_start: null,
|
||||
},
|
||||
},
|
||||
livemode: false,
|
||||
pending_webhooks: 0,
|
||||
request: {
|
||||
id: "req_6YO9stvB155WJD",
|
||||
idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
|
||||
},
|
||||
type: "customer.subscription.created",
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
if (body.type === "customer.subscription.updated" && body.data.object.status === "incomplete_expired") {
|
||||
const subscriptionID = body.data.object.id
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
await Billing.unsubscribe({ subscriptionID })
|
||||
const productID = body.data.object.items.data[0].price.product as string
|
||||
if (productID === LiteData.productID()) {
|
||||
await Billing.unsubscribeLite({ subscriptionID })
|
||||
} else if (productID === BlackData.productID()) {
|
||||
await Billing.unsubscribeBlack({ subscriptionID })
|
||||
}
|
||||
}
|
||||
if (body.type === "customer.subscription.deleted") {
|
||||
const subscriptionID = body.data.object.id
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
await Billing.unsubscribe({ subscriptionID })
|
||||
const productID = body.data.object.items.data[0].price.product as string
|
||||
if (productID === LiteData.productID()) {
|
||||
await Billing.unsubscribeLite({ subscriptionID })
|
||||
} else if (productID === BlackData.productID()) {
|
||||
await Billing.unsubscribeBlack({ subscriptionID })
|
||||
}
|
||||
}
|
||||
if (body.type === "invoice.payment_succeeded") {
|
||||
if (
|
||||
@@ -430,6 +213,7 @@ export async function POST(input: APIEvent) {
|
||||
typeof subscriptionData.discounts[0] === "string"
|
||||
? subscriptionData.discounts[0]
|
||||
: subscriptionData.discounts[0]?.coupon?.id
|
||||
const productID = subscriptionData.items.data[0].price.product as string
|
||||
|
||||
// get payment id from invoice
|
||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||
@@ -459,7 +243,7 @@ export async function POST(input: APIEvent) {
|
||||
invoiceID,
|
||||
customerID,
|
||||
enrichment: {
|
||||
type: "subscription",
|
||||
type: productID === LiteData.productID() ? "lite" : "subscription",
|
||||
couponID,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -90,7 +90,7 @@ const enroll = action(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
await Billing.subscribe({ seats: 1 })
|
||||
await Billing.subscribeBlack({ seats: 1 })
|
||||
return { error: undefined }
|
||||
}, workspaceID).catch((e) => ({ error: e.message as string })),
|
||||
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
|
||||
|
||||
@@ -3,7 +3,8 @@ import { BillingSection } from "./billing-section"
|
||||
import { ReloadSection } from "./reload-section"
|
||||
import { PaymentSection } from "./payment-section"
|
||||
import { BlackSection } from "./black-section"
|
||||
import { Show } from "solid-js"
|
||||
import { LiteSection } from "./lite-section"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { createAsync, useParams } from "@solidjs/router"
|
||||
import { queryBillingInfo, querySessionInfo } from "../../common"
|
||||
|
||||
@@ -11,14 +12,18 @@ export default function () {
|
||||
const params = useParams()
|
||||
const sessionInfo = createAsync(() => querySessionInfo(params.id!))
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<div data-slot="sections">
|
||||
<Show when={sessionInfo()?.isAdmin}>
|
||||
<Show when={billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked}>
|
||||
<Show when={isBlack()}>
|
||||
<BlackSection />
|
||||
</Show>
|
||||
<Show when={!isBlack()}>
|
||||
<LiteSection />
|
||||
</Show>
|
||||
<BillingSection />
|
||||
<Show when={billingInfo()?.customerID}>
|
||||
<ReloadSection />
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
.root {
|
||||
[data-slot="title-row"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
[data-slot="usage"] {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-4);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="usage-item"] {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="usage-header"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
[data-slot="usage-label"] {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
[data-slot="usage-value"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="progress"] {
|
||||
height: 8px;
|
||||
background-color: var(--color-bg-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="progress-bar"] {
|
||||
height: 100%;
|
||||
background-color: var(--color-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
[data-slot="reset-time"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="setting-row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="toggle-label"] {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 2.5rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: #ccc;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.125rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + span {
|
||||
background-color: #21ad0e;
|
||||
border-color: #148605;
|
||||
|
||||
&::before {
|
||||
transform: translateX(1rem) translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover span {
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
input:checked:hover + span {
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input:disabled + span {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="beta-notice"] {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg-surface);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: var(--space-3);
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="other-message"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[data-slot="promo-description"] {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="promo-models-title"] {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
[data-slot="promo-models"] {
|
||||
margin: var(--space-2) 0 0 var(--space-4);
|
||||
padding: 0;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
[data-slot="subscribe-button"] {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Show } from "solid-js"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { queryBillingInfo } from "../../common"
|
||||
import styles from "./lite-section.module.css"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
|
||||
const queryLiteSubscription = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const row = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
userID: LiteTable.userID,
|
||||
rollingUsage: LiteTable.rollingUsage,
|
||||
weeklyUsage: LiteTable.weeklyUsage,
|
||||
monthlyUsage: LiteTable.monthlyUsage,
|
||||
timeRollingUpdated: LiteTable.timeRollingUpdated,
|
||||
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
|
||||
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
|
||||
timeCreated: LiteTable.timeCreated,
|
||||
lite: BillingTable.lite,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID))
|
||||
.where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted)))
|
||||
.then((r) => r[0]),
|
||||
)
|
||||
if (!row) return null
|
||||
|
||||
const limits = LiteData.getLimits()
|
||||
const mine = row.userID === Actor.userID()
|
||||
|
||||
return {
|
||||
mine,
|
||||
useBalance: row.lite?.useBalance ?? false,
|
||||
rollingUsage: Subscription.analyzeRollingUsage({
|
||||
limit: limits.rollingLimit,
|
||||
window: limits.rollingWindow,
|
||||
usage: row.rollingUsage ?? 0,
|
||||
timeUpdated: row.timeRollingUpdated ?? new Date(),
|
||||
}),
|
||||
weeklyUsage: Subscription.analyzeWeeklyUsage({
|
||||
limit: limits.weeklyLimit,
|
||||
usage: row.weeklyUsage ?? 0,
|
||||
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
|
||||
}),
|
||||
monthlyUsage: Subscription.analyzeMonthlyUsage({
|
||||
limit: limits.monthlyLimit,
|
||||
usage: row.monthlyUsage ?? 0,
|
||||
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
|
||||
timeSubscribed: row.timeCreated,
|
||||
}),
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "lite.subscription.get")
|
||||
|
||||
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) {
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
return `${days} ${days === 1 ? i18n.t("workspace.lite.time.day") : i18n.t("workspace.lite.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")}`
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours >= 1)
|
||||
return `${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
|
||||
if (minutes === 0) return i18n.t("workspace.lite.time.fewSeconds")
|
||||
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
|
||||
}
|
||||
|
||||
const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({
|
||||
error: e.message as string,
|
||||
data: undefined,
|
||||
})),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
|
||||
)
|
||||
}, "liteCheckoutUrl")
|
||||
|
||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.generateSessionUrl({ returnUrl })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({
|
||||
error: e.message as string,
|
||||
data: undefined,
|
||||
})),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
|
||||
)
|
||||
}, "liteSessionUrl")
|
||||
|
||||
const setLiteUseBalance = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
||||
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
lite: useBalance ? { useBalance: true } : {},
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
)
|
||||
return { error: undefined }
|
||||
}, workspaceID).catch((e) => ({ error: e.message as string })),
|
||||
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
|
||||
)
|
||||
}, "setLiteUseBalance")
|
||||
|
||||
export function LiteSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const lite = createAsync(() => queryLiteSubscription(params.id!))
|
||||
const sessionAction = useAction(createSessionUrl)
|
||||
const sessionSubmission = useSubmission(createSessionUrl)
|
||||
const checkoutAction = useAction(createLiteCheckoutUrl)
|
||||
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
|
||||
const useBalanceSubmission = useSubmission(setLiteUseBalance)
|
||||
const [store, setStore] = createStore({
|
||||
redirecting: false,
|
||||
})
|
||||
|
||||
async function onClickSession() {
|
||||
const result = await sessionAction(params.id!, window.location.href)
|
||||
if (result.data) {
|
||||
setStore("redirecting", true)
|
||||
window.location.href = result.data
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickSubscribe() {
|
||||
const result = await checkoutAction(params.id!, window.location.href, window.location.href)
|
||||
if (result.data) {
|
||||
setStore("redirecting", true)
|
||||
window.location.href = result.data
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={lite() && lite()!.mine && lite()!}>
|
||||
{(sub) => (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.lite.subscription.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>{i18n.t("workspace.lite.subscription.message")}</p>
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={sessionSubmission.pending || store.redirecting}
|
||||
onClick={onClickSession}
|
||||
>
|
||||
{sessionSubmission.pending || store.redirecting
|
||||
? i18n.t("workspace.lite.loading")
|
||||
: i18n.t("workspace.lite.subscription.manage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="beta-notice">
|
||||
{i18n.t("workspace.lite.subscription.selectProvider")}{" "}
|
||||
<a href={language.route("/docs/providers/#opencode-go")} target="_blank" rel="noopener noreferrer">
|
||||
{i18n.t("common.learnMore")}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
<div data-slot="usage">
|
||||
<div data-slot="usage-item">
|
||||
<div data-slot="usage-header">
|
||||
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.rollingUsage")}</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">
|
||||
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
|
||||
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="usage-item">
|
||||
<div data-slot="usage-header">
|
||||
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.weeklyUsage")}</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">
|
||||
{i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="usage-item">
|
||||
<div data-slot="usage-header">
|
||||
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.monthlyUsage")}</span>
|
||||
<span data-slot="usage-value">{sub().monthlyUsage.usagePercent}%</span>
|
||||
</div>
|
||||
<div data-slot="progress">
|
||||
<div data-slot="progress-bar" style={{ width: `${sub().monthlyUsage.usagePercent}%` }} />
|
||||
</div>
|
||||
<span data-slot="reset-time">
|
||||
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
|
||||
{formatResetTime(sub().monthlyUsage.resetInSec, i18n)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<form action={setLiteUseBalance} method="post" data-slot="setting-row">
|
||||
<p>{i18n.t("workspace.lite.subscription.useBalance")}</p>
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
|
||||
<label data-slot="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sub().useBalance}
|
||||
disabled={useBalanceSubmission.pending}
|
||||
onChange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={lite() && !lite()!.mine}>
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.lite.other.title")}</h2>
|
||||
</div>
|
||||
<p data-slot="other-message">{i18n.t("workspace.lite.other.message")}</p>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={lite() === null}>
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.lite.promo.title")}</h2>
|
||||
</div>
|
||||
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.description")}</p>
|
||||
<h3 data-slot="promo-models-title">{i18n.t("workspace.lite.promo.modelsTitle")}</h3>
|
||||
<ul data-slot="promo-models">
|
||||
<li>Kimi K2.5</li>
|
||||
<li>GLM-5</li>
|
||||
<li>MiniMax M2.5</li>
|
||||
</ul>
|
||||
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
|
||||
<button
|
||||
data-slot="subscribe-button"
|
||||
data-color="primary"
|
||||
disabled={checkoutSubmission.pending || store.redirecting}
|
||||
onClick={onClickSubscribe}
|
||||
>
|
||||
{checkoutSubmission.pending || store.redirecting
|
||||
? i18n.t("workspace.lite.promo.subscribing")
|
||||
: i18n.t("workspace.lite.promo.subscribe")}
|
||||
</button>
|
||||
</section>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
|
||||
model: UsageTable.model,
|
||||
totalCost: sum(UsageTable.cost),
|
||||
keyId: UsageTable.keyID,
|
||||
subscription: sql<boolean>`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`,
|
||||
plan: sql<string | null>`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(
|
||||
@@ -50,13 +50,13 @@ async function getCosts(workspaceID: string, year: number, month: number) {
|
||||
sql`DATE(${UsageTable.timeCreated})`,
|
||||
UsageTable.model,
|
||||
UsageTable.keyID,
|
||||
sql`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`,
|
||||
sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
|
||||
)
|
||||
.then((x) =>
|
||||
x.map((r) => ({
|
||||
...r,
|
||||
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
|
||||
subscription: Boolean(r.subscription),
|
||||
plan: r.plan as "sub" | "lite" | "byok" | null,
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -218,18 +218,21 @@ export function GraphSection() {
|
||||
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
|
||||
const colorBorder = styles.getPropertyValue("--color-border").trim()
|
||||
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
|
||||
const liteSuffix = ` (${i18n.t("workspace.cost.liteShort")})`
|
||||
|
||||
const dailyDataRegular = new Map<string, Map<string, number>>()
|
||||
const dailyDataSub = new Map<string, Map<string, number>>()
|
||||
const dailyDataNonSub = new Map<string, Map<string, number>>()
|
||||
const dailyDataLite = new Map<string, Map<string, number>>()
|
||||
for (const dateKey of dates) {
|
||||
dailyDataRegular.set(dateKey, new Map())
|
||||
dailyDataSub.set(dateKey, new Map())
|
||||
dailyDataNonSub.set(dateKey, new Map())
|
||||
dailyDataLite.set(dateKey, new Map())
|
||||
}
|
||||
|
||||
data.usage
|
||||
.filter((row) => (store.key ? row.keyId === store.key : true))
|
||||
.forEach((row) => {
|
||||
const targetMap = row.subscription ? dailyDataSub : dailyDataNonSub
|
||||
const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular
|
||||
const dayMap = targetMap.get(row.date)
|
||||
if (!dayMap) return
|
||||
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
|
||||
@@ -237,15 +240,15 @@ export function GraphSection() {
|
||||
|
||||
const filteredModels = store.model === null ? getModels() : [store.model]
|
||||
|
||||
// Create datasets: non-subscription first, then subscription (with hatched pattern effect via opacity)
|
||||
// Create datasets: regular first, then subscription, then lite (with visual distinction via opacity)
|
||||
const datasets = [
|
||||
...filteredModels
|
||||
.filter((model) => dates.some((date) => (dailyDataNonSub.get(date)?.get(model) || 0) > 0))
|
||||
.filter((model) => dates.some((date) => (dailyDataRegular.get(date)?.get(model) || 0) > 0))
|
||||
.map((model) => {
|
||||
const color = getModelColor(model)
|
||||
return {
|
||||
label: model,
|
||||
data: dates.map((date) => (dailyDataNonSub.get(date)?.get(model) || 0) / 100_000_000),
|
||||
data: dates.map((date) => (dailyDataRegular.get(date)?.get(model) || 0) / 100_000_000),
|
||||
backgroundColor: color,
|
||||
hoverBackgroundColor: color,
|
||||
borderWidth: 0,
|
||||
@@ -266,6 +269,21 @@ export function GraphSection() {
|
||||
stack: "subscription",
|
||||
}
|
||||
}),
|
||||
...filteredModels
|
||||
.filter((model) => dates.some((date) => (dailyDataLite.get(date)?.get(model) || 0) > 0))
|
||||
.map((model) => {
|
||||
const color = getModelColor(model)
|
||||
return {
|
||||
label: `${model}${liteSuffix}`,
|
||||
data: dates.map((date) => (dailyDataLite.get(date)?.get(model) || 0) / 100_000_000),
|
||||
backgroundColor: addOpacityToColor(color, 0.35),
|
||||
hoverBackgroundColor: addOpacityToColor(color, 0.55),
|
||||
borderWidth: 1,
|
||||
borderColor: addOpacityToColor(color, 0.7),
|
||||
borderDash: [4, 2],
|
||||
stack: "lite",
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
return {
|
||||
@@ -347,9 +365,18 @@ export function GraphSection() {
|
||||
const meta = chart.getDatasetMeta(i)
|
||||
const label = dataset.label || ""
|
||||
const isSub = label.endsWith(subSuffix)
|
||||
const model = isSub ? label.slice(0, -subSuffix.length) : label
|
||||
const isLite = label.endsWith(liteSuffix)
|
||||
const model = isSub
|
||||
? label.slice(0, -subSuffix.length)
|
||||
: isLite
|
||||
? label.slice(0, -liteSuffix.length)
|
||||
: label
|
||||
const baseColor = getModelColor(model)
|
||||
const originalColor = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
|
||||
const originalColor = isSub
|
||||
? addOpacityToColor(baseColor, 0.5)
|
||||
: isLite
|
||||
? addOpacityToColor(baseColor, 0.35)
|
||||
: baseColor
|
||||
const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15)
|
||||
meta.data.forEach((bar: any) => {
|
||||
bar.options.backgroundColor = color
|
||||
@@ -363,9 +390,18 @@ export function GraphSection() {
|
||||
const meta = chart.getDatasetMeta(i)
|
||||
const label = dataset.label || ""
|
||||
const isSub = label.endsWith(subSuffix)
|
||||
const model = isSub ? label.slice(0, -subSuffix.length) : label
|
||||
const isLite = label.endsWith(liteSuffix)
|
||||
const model = isSub
|
||||
? label.slice(0, -subSuffix.length)
|
||||
: isLite
|
||||
? label.slice(0, -liteSuffix.length)
|
||||
: label
|
||||
const baseColor = getModelColor(model)
|
||||
const color = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
|
||||
const color = isSub
|
||||
? addOpacityToColor(baseColor, 0.5)
|
||||
: isLite
|
||||
? addOpacityToColor(baseColor, 0.35)
|
||||
: baseColor
|
||||
meta.data.forEach((bar: any) => {
|
||||
bar.options.backgroundColor = color
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, For, Show, createEffect, createSignal } from "solid-js"
|
||||
import { createMemo, For, Show, Switch, Match, createEffect, createSignal } from "solid-js"
|
||||
import { formatDateUTC, formatDateForTable } from "../common"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
|
||||
@@ -175,14 +175,23 @@ export function UsageSection() {
|
||||
</div>
|
||||
</td>
|
||||
<td data-slot="usage-cost">
|
||||
<Show
|
||||
when={usage.enrichment?.plan === "sub"}
|
||||
fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}
|
||||
>
|
||||
{i18n.t("workspace.usage.subscription", {
|
||||
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
|
||||
})}
|
||||
</Show>
|
||||
<Switch fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}>
|
||||
<Match when={usage.enrichment?.plan === "sub"}>
|
||||
{i18n.t("workspace.usage.subscription", {
|
||||
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
|
||||
})}
|
||||
</Match>
|
||||
<Match when={usage.enrichment?.plan === "lite"}>
|
||||
{i18n.t("workspace.usage.lite", {
|
||||
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
|
||||
})}
|
||||
</Match>
|
||||
<Match when={usage.enrichment?.plan === "byok"}>
|
||||
{i18n.t("workspace.usage.byok", {
|
||||
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
|
||||
})}
|
||||
</Match>
|
||||
</Switch>
|
||||
</td>
|
||||
<td data-slot="usage-session">{usage.sessionID?.slice(-8) ?? "-"}</td>
|
||||
</tr>
|
||||
|
||||
@@ -115,6 +115,8 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
|
||||
subscriptionPlan: billing.subscriptionPlan,
|
||||
timeSubscriptionBooked: billing.timeSubscriptionBooked,
|
||||
timeSubscriptionSelected: billing.timeSubscriptionSelected,
|
||||
lite: billing.lite,
|
||||
liteSubscriptionID: billing.liteSubscriptionID,
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "billing.get")
|
||||
|
||||
12
packages/console/app/src/routes/zen/go/v1/messages.ts
Normal file
12
packages/console/app/src/routes/zen/go/v1/messages.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
format: "anthropic",
|
||||
modelList: "lite",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { BillingTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { BillingTable, LiteTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { getWeekBounds } from "@opencode-ai/console-core/util/date.js"
|
||||
import { getMonthlyBounds, getWeekBounds } from "@opencode-ai/console-core/util/date.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
@@ -33,13 +33,15 @@ import { createRateLimiter } from "./rateLimiter"
|
||||
import { createDataDumper } from "./dataDumper"
|
||||
import { createTrialLimiter } from "./trialLimiter"
|
||||
import { createStickyTracker } from "./stickyProviderTracker"
|
||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||
type RetryOptions = {
|
||||
excludeProviders: string[]
|
||||
retryCount: number
|
||||
}
|
||||
type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "balance"
|
||||
type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "lite" | "balance"
|
||||
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
@@ -58,7 +60,7 @@ export async function handler(
|
||||
|
||||
const MAX_FAILOVER_RETRIES = 3
|
||||
const MAX_429_RETRIES = 3
|
||||
const FREE_WORKSPACES = [
|
||||
const ADMIN_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
||||
]
|
||||
@@ -454,6 +456,7 @@ export async function handler(
|
||||
reloadTrigger: BillingTable.reloadTrigger,
|
||||
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
|
||||
subscription: BillingTable.subscription,
|
||||
lite: BillingTable.lite,
|
||||
},
|
||||
user: {
|
||||
id: UserTable.id,
|
||||
@@ -461,13 +464,23 @@ export async function handler(
|
||||
monthlyUsage: UserTable.monthlyUsage,
|
||||
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
||||
},
|
||||
subscription: {
|
||||
black: {
|
||||
id: SubscriptionTable.id,
|
||||
rollingUsage: SubscriptionTable.rollingUsage,
|
||||
fixedUsage: SubscriptionTable.fixedUsage,
|
||||
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||
},
|
||||
lite: {
|
||||
id: LiteTable.id,
|
||||
timeCreated: LiteTable.timeCreated,
|
||||
rollingUsage: LiteTable.rollingUsage,
|
||||
weeklyUsage: LiteTable.weeklyUsage,
|
||||
monthlyUsage: LiteTable.monthlyUsage,
|
||||
timeRollingUpdated: LiteTable.timeRollingUpdated,
|
||||
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
|
||||
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
|
||||
},
|
||||
provider: {
|
||||
credentials: ProviderTable.credentials,
|
||||
},
|
||||
@@ -495,16 +508,42 @@ export async function handler(
|
||||
isNull(SubscriptionTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
LiteTable,
|
||||
and(
|
||||
eq(LiteTable.workspaceID, KeyTable.workspaceID),
|
||||
eq(LiteTable.userID, KeyTable.userID),
|
||||
isNull(LiteTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!data) throw new AuthError("Invalid API key.")
|
||||
if (
|
||||
modelInfo.id.startsWith("alpha-") &&
|
||||
Resource.App.stage === "production" &&
|
||||
!ADMIN_WORKSPACES.includes(data.workspaceID)
|
||||
)
|
||||
throw new AuthError(`Model ${modelInfo.id} not supported`)
|
||||
|
||||
logger.metric({
|
||||
api_key: data.apiKey,
|
||||
workspace: data.workspaceID,
|
||||
isSubscription: data.subscription ? true : false,
|
||||
subscription: data.billing.subscription?.plan,
|
||||
...(() => {
|
||||
if (data.billing.subscription)
|
||||
return {
|
||||
isSubscription: true,
|
||||
subscription: data.billing.subscription.plan,
|
||||
}
|
||||
if (data.billing.lite)
|
||||
return {
|
||||
isSubscription: true,
|
||||
subscription: "lite",
|
||||
}
|
||||
return {}
|
||||
})(),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -512,9 +551,10 @@ export async function handler(
|
||||
workspaceID: data.workspaceID,
|
||||
billing: data.billing,
|
||||
user: data.user,
|
||||
subscription: data.subscription,
|
||||
black: data.black,
|
||||
lite: data.lite,
|
||||
provider: data.provider,
|
||||
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
||||
isFree: ADMIN_WORKSPACES.includes(data.workspaceID),
|
||||
isDisabled: !!data.timeDisabled,
|
||||
}
|
||||
}
|
||||
@@ -525,20 +565,20 @@ export async function handler(
|
||||
if (authInfo.isFree) return "free"
|
||||
if (modelInfo.allowAnonymous) return "free"
|
||||
|
||||
// Validate subscription billing
|
||||
if (authInfo.billing.subscription && authInfo.subscription) {
|
||||
try {
|
||||
const sub = authInfo.subscription
|
||||
const plan = authInfo.billing.subscription.plan
|
||||
const formatRetryTime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||
return `${minutes}min`
|
||||
}
|
||||
|
||||
const formatRetryTime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||
return `${minutes}min`
|
||||
}
|
||||
// Validate black subscription billing
|
||||
if (authInfo.billing.subscription && authInfo.black) {
|
||||
try {
|
||||
const sub = authInfo.black
|
||||
const plan = authInfo.billing.subscription.plan
|
||||
|
||||
// Check weekly limit
|
||||
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
||||
@@ -577,6 +617,62 @@ export async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate lite subscription billing
|
||||
if (opts.modelList === "lite" && authInfo.billing.lite && authInfo.lite) {
|
||||
try {
|
||||
const sub = authInfo.lite
|
||||
const liteData = LiteData.getLimits()
|
||||
|
||||
// Check weekly limit
|
||||
if (sub.weeklyUsage && sub.timeWeeklyUpdated) {
|
||||
const result = Subscription.analyzeWeeklyUsage({
|
||||
limit: liteData.weeklyLimit,
|
||||
usage: sub.weeklyUsage,
|
||||
timeUpdated: sub.timeWeeklyUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
// Check monthly limit
|
||||
if (sub.monthlyUsage && sub.timeMonthlyUpdated) {
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: liteData.monthlyLimit,
|
||||
usage: sub.monthlyUsage,
|
||||
timeUpdated: sub.timeMonthlyUpdated,
|
||||
timeSubscribed: sub.timeCreated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
// Check rolling limit
|
||||
if (sub.monthlyUsage && sub.timeMonthlyUpdated) {
|
||||
const result = Subscription.analyzeRollingUsage({
|
||||
limit: liteData.rollingLimit,
|
||||
window: liteData.rollingWindow,
|
||||
usage: sub.monthlyUsage,
|
||||
timeUpdated: sub.timeMonthlyUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
return "lite"
|
||||
} catch (e) {
|
||||
if (!authInfo.billing.lite.useBalance) throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Validate pay as you go billing
|
||||
const billing = authInfo.billing
|
||||
if (!billing.paymentMethodID)
|
||||
@@ -740,79 +836,126 @@ export async function handler(
|
||||
cost,
|
||||
keyID: authInfo.apiKeyId,
|
||||
sessionID: sessionId.substring(0, 30),
|
||||
enrichment: billingSource === "subscription" ? { plan: "sub" } : undefined,
|
||||
enrichment: (() => {
|
||||
if (billingSource === "subscription") return { plan: "sub" }
|
||||
if (billingSource === "byok") return { plan: "byok" }
|
||||
if (billingSource === "lite") return { plan: "lite" }
|
||||
return undefined
|
||||
})(),
|
||||
}),
|
||||
db
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||
...(billingSource === "subscription"
|
||||
? (() => {
|
||||
const plan = authInfo.billing.subscription!.plan
|
||||
const black = BlackData.getLimits({ plan })
|
||||
const week = getWeekBounds(new Date())
|
||||
const rollingWindowSeconds = black.rollingWindow * 3600
|
||||
return [
|
||||
db
|
||||
.update(SubscriptionTable)
|
||||
.set({
|
||||
fixedUsage: sql`
|
||||
...(() => {
|
||||
if (billingSource === "subscription") {
|
||||
const plan = authInfo.billing.subscription!.plan
|
||||
const black = BlackData.getLimits({ plan })
|
||||
const week = getWeekBounds(new Date())
|
||||
const rollingWindowSeconds = black.rollingWindow * 3600
|
||||
return [
|
||||
db
|
||||
.update(SubscriptionTable)
|
||||
.set({
|
||||
fixedUsage: sql`
|
||||
CASE
|
||||
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeFixedUpdated: sql`now()`,
|
||||
rollingUsage: sql`
|
||||
timeFixedUpdated: sql`now()`,
|
||||
rollingUsage: sql`
|
||||
CASE
|
||||
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeRollingUpdated: sql`
|
||||
timeRollingUpdated: sql`
|
||||
CASE
|
||||
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated}
|
||||
ELSE now()
|
||||
END
|
||||
`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
|
||||
eq(SubscriptionTable.userID, authInfo.user.id),
|
||||
),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
|
||||
eq(SubscriptionTable.userID, authInfo.user.id),
|
||||
),
|
||||
]
|
||||
})()
|
||||
: [
|
||||
),
|
||||
]
|
||||
}
|
||||
if (billingSource === "lite") {
|
||||
const lite = LiteData.getLimits()
|
||||
const week = getWeekBounds(new Date())
|
||||
const month = getMonthlyBounds(new Date(), authInfo.lite!.timeCreated)
|
||||
const rollingWindowSeconds = lite.rollingWindow * 3600
|
||||
return [
|
||||
db
|
||||
.update(BillingTable)
|
||||
.update(LiteTable)
|
||||
.set({
|
||||
balance: authInfo.isFree
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN ${LiteTable.timeMonthlyUpdated} >= ${month.start} THEN ${LiteTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUpdated: sql`now()`,
|
||||
weeklyUsage: sql`
|
||||
CASE
|
||||
WHEN ${LiteTable.timeWeeklyUpdated} >= ${week.start} THEN ${LiteTable.weeklyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeWeeklyUpdated: sql`now()`,
|
||||
rollingUsage: sql`
|
||||
CASE
|
||||
WHEN UNIX_TIMESTAMP(${LiteTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${LiteTable.rollingUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeRollingUpdated: sql`
|
||||
CASE
|
||||
WHEN UNIX_TIMESTAMP(${LiteTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${LiteTable.timeRollingUpdated}
|
||||
ELSE now()
|
||||
END
|
||||
`,
|
||||
})
|
||||
.where(and(eq(LiteTable.workspaceID, authInfo.workspaceID), eq(LiteTable.userID, authInfo.user.id))),
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
db
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance:
|
||||
billingSource === "free" || billingSource === "byok"
|
||||
? sql`${BillingTable.balance} - ${0}`
|
||||
: sql`${BillingTable.balance} - ${cost}`,
|
||||
monthlyUsage: sql`
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
|
||||
db
|
||||
.update(UserTable)
|
||||
.set({
|
||||
monthlyUsage: sql`
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
|
||||
db
|
||||
.update(UserTable)
|
||||
.set({
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||
]),
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||
]
|
||||
})(),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function GET(input: APIEvent) {
|
||||
object: "list",
|
||||
data: Object.entries(zenData.models)
|
||||
.filter(([id]) => !disabledModels.includes(id))
|
||||
.filter(([id]) => !id.startsWith("alpha-"))
|
||||
.map(([id, _model]) => ({
|
||||
id,
|
||||
object: "model",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE `lite` (
|
||||
`id` varchar(30) NOT NULL,
|
||||
`workspace_id` varchar(30) NOT NULL,
|
||||
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`time_deleted` timestamp(3),
|
||||
`user_id` varchar(30) NOT NULL,
|
||||
`rolling_usage` bigint,
|
||||
`weekly_usage` bigint,
|
||||
`monthly_usage` bigint,
|
||||
`time_rolling_updated` timestamp(3),
|
||||
`time_weekly_updated` timestamp(3),
|
||||
`time_monthly_updated` timestamp(3),
|
||||
CONSTRAINT `PRIMARY` PRIMARY KEY(`workspace_id`,`id`),
|
||||
CONSTRAINT `workspace_user_id` UNIQUE INDEX(`workspace_id`,`user_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `billing` ADD `lite_subscription_id` varchar(28);--> statement-breakpoint
|
||||
ALTER TABLE `billing` ADD `lite` json;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Database, eq, and, sql, inArray, isNull, count } from "../src/drizzle/index.js"
|
||||
import { BillingTable, SubscriptionPlan } from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, BlackPlans } from "../src/schema/billing.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
|
||||
const plan = process.argv[2] as (typeof SubscriptionPlan)[number]
|
||||
if (!SubscriptionPlan.includes(plan)) {
|
||||
const plan = process.argv[2] as (typeof BlackPlans)[number]
|
||||
if (!BlackPlans.includes(plan)) {
|
||||
console.error("Usage: bun foo.ts <count>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Database, and, eq, sql } from "../src/drizzle/index.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import {
|
||||
BillingTable,
|
||||
PaymentTable,
|
||||
SubscriptionTable,
|
||||
SubscriptionPlan,
|
||||
UsageTable,
|
||||
} from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
@@ -235,7 +229,7 @@ function formatRetryTime(seconds: number) {
|
||||
|
||||
function getSubscriptionStatus(row: {
|
||||
subscription: {
|
||||
plan: (typeof SubscriptionPlan)[number]
|
||||
plan: (typeof BlackPlans)[number]
|
||||
} | null
|
||||
timeSubscriptionCreated: Date | null
|
||||
fixedUsage: number | null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Stripe } from "stripe"
|
||||
import { Database, eq, sql } from "./drizzle"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
|
||||
import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
|
||||
import { Actor } from "./actor"
|
||||
import { fn } from "./util/fn"
|
||||
import { z } from "zod"
|
||||
@@ -9,6 +9,7 @@ import { Identifier } from "./identifier"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
import { User } from "./user"
|
||||
import { BlackData } from "./black"
|
||||
import { LiteData } from "./lite"
|
||||
|
||||
export namespace Billing {
|
||||
export const ITEM_CREDIT_NAME = "opencode credits"
|
||||
@@ -233,6 +234,56 @@ export namespace Billing {
|
||||
},
|
||||
)
|
||||
|
||||
export const generateLiteCheckoutUrl = fn(
|
||||
z.object({
|
||||
successUrl: z.string(),
|
||||
cancelUrl: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
const user = Actor.assert("user")
|
||||
const { successUrl, cancelUrl } = input
|
||||
|
||||
const email = await User.getAuthEmail(user.properties.userID)
|
||||
const billing = await Billing.get()
|
||||
|
||||
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
||||
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
||||
|
||||
const session = await Billing.stripe().checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
billing_address_collection: "required",
|
||||
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
|
||||
...(billing.customerID
|
||||
? {
|
||||
customer: billing.customerID,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
}
|
||||
: {
|
||||
customer_email: email!,
|
||||
}),
|
||||
currency: "usd",
|
||||
payment_method_types: ["card"],
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
userID: user.properties.userID,
|
||||
type: "lite",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return session.url
|
||||
},
|
||||
)
|
||||
|
||||
export const generateSessionUrl = fn(
|
||||
z.object({
|
||||
returnUrl: z.string(),
|
||||
@@ -271,7 +322,7 @@ export namespace Billing {
|
||||
},
|
||||
)
|
||||
|
||||
export const subscribe = fn(
|
||||
export const subscribeBlack = fn(
|
||||
z.object({
|
||||
seats: z.number(),
|
||||
coupon: z.string().optional(),
|
||||
@@ -336,7 +387,7 @@ export namespace Billing {
|
||||
},
|
||||
)
|
||||
|
||||
export const unsubscribe = fn(
|
||||
export const unsubscribeBlack = fn(
|
||||
z.object({
|
||||
subscriptionID: z.string(),
|
||||
}),
|
||||
@@ -360,4 +411,29 @@ export namespace Billing {
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const unsubscribeLite = fn(
|
||||
z.object({
|
||||
subscriptionID: z.string(),
|
||||
}),
|
||||
async ({ subscriptionID }) => {
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.liteSubscriptionID, subscriptionID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({ liteSubscriptionID: null, lite: null })
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.delete(LiteTable).where(eq(LiteTable.workspaceID, workspaceID))
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { SubscriptionPlan } from "./schema/billing.sql"
|
||||
import { BlackPlans } from "./schema/billing.sql"
|
||||
|
||||
export namespace BlackData {
|
||||
const Schema = z.object({
|
||||
@@ -28,7 +28,7 @@ export namespace BlackData {
|
||||
|
||||
export const getLimits = fn(
|
||||
z.object({
|
||||
plan: z.enum(SubscriptionPlan),
|
||||
plan: z.enum(BlackPlans),
|
||||
}),
|
||||
({ plan }) => {
|
||||
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
|
||||
@@ -36,9 +36,11 @@ export namespace BlackData {
|
||||
},
|
||||
)
|
||||
|
||||
export const productID = fn(z.void(), () => Resource.ZEN_BLACK_PRICE.product)
|
||||
|
||||
export const planToPriceID = fn(
|
||||
z.object({
|
||||
plan: z.enum(SubscriptionPlan),
|
||||
plan: z.enum(BlackPlans),
|
||||
}),
|
||||
({ plan }) => {
|
||||
if (plan === "200") return Resource.ZEN_BLACK_PRICE.plan200
|
||||
|
||||
@@ -8,6 +8,7 @@ export namespace Identifier {
|
||||
benchmark: "ben",
|
||||
billing: "bil",
|
||||
key: "key",
|
||||
lite: "lit",
|
||||
model: "mod",
|
||||
payment: "pay",
|
||||
provider: "prv",
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export namespace LiteData {
|
||||
const Schema = z.object({
|
||||
fixedLimit: z.number().int(),
|
||||
rollingLimit: z.number().int(),
|
||||
rollingWindow: z.number().int(),
|
||||
weeklyLimit: z.number().int(),
|
||||
monthlyLimit: z.number().int(),
|
||||
})
|
||||
|
||||
export const validate = fn(Schema, (input) => {
|
||||
@@ -18,11 +19,7 @@ export namespace LiteData {
|
||||
return Schema.parse(json)
|
||||
})
|
||||
|
||||
export const planToPriceID = fn(z.void(), () => {
|
||||
return Resource.ZEN_LITE_PRICE.price
|
||||
})
|
||||
|
||||
export const priceIDToPlan = fn(z.void(), () => {
|
||||
return "lite"
|
||||
})
|
||||
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
||||
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
||||
export const planName = fn(z.void(), () => "lite")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex,
|
||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const SubscriptionPlan = ["20", "100", "200"] as const
|
||||
export const BlackPlans = ["20", "100", "200"] as const
|
||||
export const BillingTable = mysqlTable(
|
||||
"billing",
|
||||
{
|
||||
@@ -25,14 +25,18 @@ export const BillingTable = mysqlTable(
|
||||
subscription: json("subscription").$type<{
|
||||
status: "subscribed"
|
||||
seats: number
|
||||
plan: "20" | "100" | "200"
|
||||
plan: (typeof BlackPlans)[number]
|
||||
useBalance?: boolean
|
||||
coupon?: string
|
||||
}>(),
|
||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", BlackPlans),
|
||||
timeSubscriptionBooked: utc("time_subscription_booked"),
|
||||
timeSubscriptionSelected: utc("time_subscription_selected"),
|
||||
liteSubscriptionID: varchar("lite_subscription_id", { length: 28 }),
|
||||
lite: json("lite").$type<{
|
||||
useBalance?: boolean
|
||||
}>(),
|
||||
},
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
@@ -55,6 +59,22 @@ export const SubscriptionTable = mysqlTable(
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||
)
|
||||
|
||||
export const LiteTable = mysqlTable(
|
||||
"lite",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
userID: ulid("user_id").notNull(),
|
||||
rollingUsage: bigint("rolling_usage", { mode: "number" }),
|
||||
weeklyUsage: bigint("weekly_usage", { mode: "number" }),
|
||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||
timeRollingUpdated: utc("time_rolling_updated"),
|
||||
timeWeeklyUpdated: utc("time_weekly_updated"),
|
||||
timeMonthlyUpdated: utc("time_monthly_updated"),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||
)
|
||||
|
||||
export const PaymentTable = mysqlTable(
|
||||
"payment",
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
import { getWeekBounds } from "./util/date"
|
||||
import { getWeekBounds, getMonthlyBounds } from "./util/date"
|
||||
|
||||
export namespace Subscription {
|
||||
export const analyzeRollingUsage = fn(
|
||||
@@ -29,7 +29,7 @@ export namespace Subscription {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.ceil(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -61,7 +61,7 @@ export namespace Subscription {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.ceil(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,4 +72,38 @@ export namespace Subscription {
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const analyzeMonthlyUsage = fn(
|
||||
z.object({
|
||||
limit: z.number().int(),
|
||||
usage: z.number().int(),
|
||||
timeUpdated: z.date(),
|
||||
timeSubscribed: z.date(),
|
||||
}),
|
||||
({ limit, usage, timeUpdated, timeSubscribed }) => {
|
||||
const now = new Date()
|
||||
const month = getMonthlyBounds(now, timeSubscribed)
|
||||
const fixedLimitInMicroCents = centsToMicroCents(limit * 100)
|
||||
if (timeUpdated < month.start) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 0,
|
||||
}
|
||||
}
|
||||
if (usage < fixedLimitInMicroCents) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "rate-limited" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 100,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getWeekBounds } from "./date"
|
||||
|
||||
describe("util.date.getWeekBounds", () => {
|
||||
test("returns a Monday-based week for Sunday dates", () => {
|
||||
const date = new Date("2026-01-18T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("returns a seven day window", () => {
|
||||
const date = new Date("2026-01-14T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
const span = bounds.end.getTime() - bounds.start.getTime()
|
||||
expect(span).toBe(7 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
@@ -7,3 +7,32 @@ export function getWeekBounds(date: Date) {
|
||||
end.setUTCDate(start.getUTCDate() + 7)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export function getMonthlyBounds(now: Date, subscribed: Date) {
|
||||
const day = subscribed.getUTCDate()
|
||||
const hh = subscribed.getUTCHours()
|
||||
const mm = subscribed.getUTCMinutes()
|
||||
const ss = subscribed.getUTCSeconds()
|
||||
const ms = subscribed.getUTCMilliseconds()
|
||||
|
||||
function anchor(year: number, month: number) {
|
||||
const max = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
|
||||
return new Date(Date.UTC(year, month, Math.min(day, max), hh, mm, ss, ms))
|
||||
}
|
||||
|
||||
function shift(year: number, month: number, delta: number) {
|
||||
const total = year * 12 + month + delta
|
||||
return [Math.floor(total / 12), ((total % 12) + 12) % 12] as const
|
||||
}
|
||||
|
||||
let y = now.getUTCFullYear()
|
||||
let m = now.getUTCMonth()
|
||||
let start = anchor(y, m)
|
||||
if (start > now) {
|
||||
;[y, m] = shift(y, m, -1)
|
||||
start = anchor(y, m)
|
||||
}
|
||||
const [ny, nm] = shift(y, m, 1)
|
||||
const end = anchor(ny, nm)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
76
packages/console/core/test/date.test.ts
Normal file
76
packages/console/core/test/date.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getWeekBounds, getMonthlyBounds } from "../src/util/date"
|
||||
|
||||
describe("util.date.getWeekBounds", () => {
|
||||
test("returns a Monday-based week for Sunday dates", () => {
|
||||
const date = new Date("2026-01-18T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("returns a seven day window", () => {
|
||||
const date = new Date("2026-01-14T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
const span = bounds.end.getTime() - bounds.start.getTime()
|
||||
expect(span).toBe(7 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("util.date.getMonthlyBounds", () => {
|
||||
test("resets on subscription day mid-month", () => {
|
||||
const now = new Date("2026-03-20T10:00:00Z")
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-03-15T08:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-04-15T08:00:00.000Z")
|
||||
})
|
||||
|
||||
test("before subscription day in current month uses previous month anchor", () => {
|
||||
const now = new Date("2026-03-10T10:00:00Z")
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-02-15T08:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-03-15T08:00:00.000Z")
|
||||
})
|
||||
|
||||
test("clamps day for short months", () => {
|
||||
const now = new Date("2026-03-01T10:00:00Z")
|
||||
const subscribed = new Date("2026-01-31T12:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-02-28T12:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-03-31T12:00:00.000Z")
|
||||
})
|
||||
|
||||
test("handles subscription on the 1st", () => {
|
||||
const now = new Date("2026-04-15T00:00:00Z")
|
||||
const subscribed = new Date("2026-01-01T00:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-04-01T00:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-05-01T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("exactly on the reset boundary uses current period", () => {
|
||||
const now = new Date("2026-03-15T08:00:00Z")
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-03-15T08:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-04-15T08:00:00.000Z")
|
||||
})
|
||||
|
||||
test("february to march with day 30 subscription", () => {
|
||||
const now = new Date("2026-02-15T06:00:00Z")
|
||||
const subscribed = new Date("2025-12-30T06:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-01-30T06:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-02-28T06:00:00.000Z")
|
||||
})
|
||||
})
|
||||
106
packages/console/core/test/subscription.test.ts
Normal file
106
packages/console/core/test/subscription.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test, setSystemTime, afterEach } from "bun:test"
|
||||
import { Subscription } from "../src/subscription"
|
||||
import { centsToMicroCents } from "../src/util/price"
|
||||
|
||||
afterEach(() => {
|
||||
setSystemTime()
|
||||
})
|
||||
|
||||
describe("Subscription.analyzeMonthlyUsage", () => {
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
|
||||
test("returns ok with 0% when usage was last updated before current period", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: 10,
|
||||
usage: centsToMicroCents(500),
|
||||
timeUpdated: new Date("2026-02-10T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(0)
|
||||
// reset should be seconds until 2026-04-15T08:00:00Z
|
||||
const expected = Math.ceil(
|
||||
(new Date("2026-04-15T08:00:00Z").getTime() - new Date("2026-03-20T10:00:00Z").getTime()) / 1000,
|
||||
)
|
||||
expect(result.resetInSec).toBe(expected)
|
||||
})
|
||||
|
||||
test("returns ok with usage percent when under limit", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const limit = 10 // $10
|
||||
const half = centsToMicroCents(10 * 100) / 2
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit,
|
||||
usage: half,
|
||||
timeUpdated: new Date("2026-03-18T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(50)
|
||||
})
|
||||
|
||||
test("returns rate-limited when at or over limit", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const limit = 10
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit,
|
||||
usage: centsToMicroCents(limit * 100),
|
||||
timeUpdated: new Date("2026-03-18T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("rate-limited")
|
||||
expect(result.usagePercent).toBe(100)
|
||||
})
|
||||
|
||||
test("resets usage when crossing monthly boundary", () => {
|
||||
// subscribed on 15th, now is April 16th — period is Apr 15 to May 15
|
||||
// timeUpdated is March 20 (previous period)
|
||||
setSystemTime(new Date("2026-04-16T10:00:00Z"))
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: 10,
|
||||
usage: centsToMicroCents(10 * 100),
|
||||
timeUpdated: new Date("2026-03-20T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(0)
|
||||
})
|
||||
|
||||
test("caps usage percent at 100", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const limit = 10
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit,
|
||||
usage: centsToMicroCents(limit * 100) - 1,
|
||||
timeUpdated: new Date("2026-03-18T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
test("handles subscription day 31 in short month", () => {
|
||||
const sub31 = new Date("2026-01-31T12:00:00Z")
|
||||
// now is March 1 — period should be Feb 28 to Mar 31
|
||||
setSystemTime(new Date("2026-03-01T10:00:00Z"))
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: 10,
|
||||
usage: 0,
|
||||
timeUpdated: new Date("2026-03-01T09:00:00Z"),
|
||||
timeSubscribed: sub31,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(0)
|
||||
const expected = Math.ceil(
|
||||
(new Date("2026-03-31T12:00:00Z").getTime() - new Date("2026-03-01T10:00:00Z").getTime()) / 1000,
|
||||
)
|
||||
expect(result.resetInSec).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Native OpenCode desktop app, built with Tauri v2.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Building the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
|
||||
|
||||
## Development
|
||||
|
||||
From the repo root:
|
||||
@@ -11,22 +15,18 @@ bun install
|
||||
bun run --cwd packages/desktop tauri dev
|
||||
```
|
||||
|
||||
This starts the Vite dev server on http://localhost:1420 and opens the native window.
|
||||
|
||||
If you only want the web dev server (no native shell):
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/desktop dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
To create a production `dist/` and build the native app bundle:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/desktop tauri build
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
## Troubleshooting
|
||||
|
||||
Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
|
||||
### Rust compiler not found
|
||||
|
||||
If you see errors about Rust not being found, install it via [rustup](https://rustup.rs/):
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; ass
|
||||
},
|
||||
{
|
||||
rustTarget: "x86_64-apple-darwin",
|
||||
ocBinary: "opencode-darwin-x64-baseline",
|
||||
ocBinary: "opencode-darwin-x64",
|
||||
assetExt: "zip",
|
||||
},
|
||||
{
|
||||
|
||||
5
packages/desktop/src-tauri/Cargo.lock
generated
5
packages/desktop/src-tauri/Cargo.lock
generated
@@ -1988,7 +1988,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3136,7 +3136,8 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -55,7 +55,8 @@ tokio-stream = { version = "0.1.18", features = ["sync"] }
|
||||
process-wrap = { version = "9.0.3", features = ["tokio1"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = ["Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_System_Registry"] }
|
||||
windows-core = "0.62"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
|
||||
@@ -19,7 +19,7 @@ use tokio::{
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tracing::Instrument;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
|
||||
use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
|
||||
|
||||
use crate::server::get_wsl_config;
|
||||
|
||||
@@ -32,7 +32,7 @@ struct WinCreationFlags;
|
||||
#[cfg(windows)]
|
||||
impl CommandWrapper for WinCreationFlags {
|
||||
fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> std::io::Result<()> {
|
||||
command.creation_flags((CREATE_NO_WINDOW | CREATE_SUSPENDED).0);
|
||||
command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod linux_display;
|
||||
pub mod linux_windowing;
|
||||
mod logging;
|
||||
mod markdown;
|
||||
mod os;
|
||||
mod server;
|
||||
mod window_customizer;
|
||||
mod windows;
|
||||
@@ -42,7 +43,7 @@ struct ServerReadyData {
|
||||
url: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
is_sidecar: bool
|
||||
is_sidecar: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
|
||||
@@ -148,7 +149,7 @@ async fn await_initialization(
|
||||
fn check_app_exists(app_name: &str) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
check_windows_app(app_name)
|
||||
os::windows::check_windows_app(app_name)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -162,156 +163,12 @@ fn check_app_exists(app_name: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_windows_app(_app_name: &str) -> bool {
|
||||
// Check if command exists in PATH, including .exe
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_windows_app_path(app_name: &str) -> Option<String> {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// Try to find the command using 'where'
|
||||
let output = Command::new("where").arg(app_name).output().ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let paths = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let has_ext = |path: &Path, ext: &str| {
|
||||
path.extension()
|
||||
.and_then(|v| v.to_str())
|
||||
.map(|v| v.eq_ignore_ascii_case(ext))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
let resolve_cmd = |path: &Path| -> Option<String> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
|
||||
for token in content.split('"') {
|
||||
let lower = token.to_ascii_lowercase();
|
||||
if !lower.contains(".exe") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(index) = lower.find("%~dp0") {
|
||||
let base = path.parent()?;
|
||||
let suffix = &token[index + 5..];
|
||||
let mut resolved = PathBuf::from(base);
|
||||
|
||||
for part in suffix.replace('/', "\\").split('\\') {
|
||||
if part.is_empty() || part == "." {
|
||||
continue;
|
||||
}
|
||||
if part == ".." {
|
||||
let _ = resolved.pop();
|
||||
continue;
|
||||
}
|
||||
resolved.push(part);
|
||||
}
|
||||
|
||||
if resolved.exists() {
|
||||
return Some(resolved.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = PathBuf::from(token);
|
||||
if resolved.exists() {
|
||||
return Some(resolved.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
};
|
||||
|
||||
for path in &paths {
|
||||
if has_ext(path, "cmd") || has_ext(path, "bat") {
|
||||
if let Some(resolved) = resolve_cmd(path) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
if path.extension().is_none() {
|
||||
let cmd = path.with_extension("cmd");
|
||||
if cmd.exists() {
|
||||
if let Some(resolved) = resolve_cmd(&cmd) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
let bat = path.with_extension("bat");
|
||||
if bat.exists() {
|
||||
if let Some(resolved) = resolve_cmd(&bat) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let key = app_name
|
||||
.chars()
|
||||
.filter(|v| v.is_ascii_alphanumeric())
|
||||
.flat_map(|v| v.to_lowercase())
|
||||
.collect::<String>();
|
||||
|
||||
if !key.is_empty() {
|
||||
for path in &paths {
|
||||
let dirs = [
|
||||
path.parent(),
|
||||
path.parent().and_then(|dir| dir.parent()),
|
||||
path.parent()
|
||||
.and_then(|dir| dir.parent())
|
||||
.and_then(|dir| dir.parent()),
|
||||
];
|
||||
|
||||
for dir in dirs.into_iter().flatten() {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let candidate = entry.path();
|
||||
if !has_ext(&candidate, "exe") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let name = stem
|
||||
.chars()
|
||||
.filter(|v| v.is_ascii_alphanumeric())
|
||||
.flat_map(|v| v.to_lowercase())
|
||||
.collect::<String>();
|
||||
|
||||
if name.contains(&key) || key.contains(&name) {
|
||||
return Some(candidate.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths.first().map(|path| path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
fn resolve_app_path(app_name: &str) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
resolve_windows_app_path(app_name)
|
||||
os::windows::resolve_windows_app_path(app_name)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -634,7 +491,12 @@ async fn initialize(app: AppHandle) {
|
||||
|
||||
app.state::<ServerState>().set_child(Some(child));
|
||||
|
||||
Ok(ServerReadyData { url, username,password, is_sidecar: true })
|
||||
Ok(ServerReadyData {
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
is_sidecar: true,
|
||||
})
|
||||
}
|
||||
.map(move |res| {
|
||||
let _ = server_ready_tx.send(res);
|
||||
|
||||
2
packages/desktop/src-tauri/src/os/mod.rs
Normal file
2
packages/desktop/src-tauri/src/os/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
439
packages/desktop/src-tauri/src/os/windows.rs
Normal file
439
packages/desktop/src-tauri/src/os/windows.rs
Normal file
@@ -0,0 +1,439 @@
|
||||
use std::{
|
||||
ffi::c_void,
|
||||
os::windows::process::CommandExt,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::ERROR_SUCCESS,
|
||||
System::Registry::{
|
||||
HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, REG_EXPAND_SZ, REG_SZ, RRF_RT_REG_EXPAND_SZ,
|
||||
RRF_RT_REG_SZ, RegGetValueW,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn check_windows_app(app_name: &str) -> bool {
|
||||
resolve_windows_app_path(app_name).is_some()
|
||||
}
|
||||
|
||||
pub fn resolve_windows_app_path(app_name: &str) -> Option<String> {
|
||||
fn expand_env(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut index = 0;
|
||||
|
||||
while let Some(start) = value[index..].find('%') {
|
||||
let start = index + start;
|
||||
out.push_str(&value[index..start]);
|
||||
|
||||
let Some(end_rel) = value[start + 1..].find('%') else {
|
||||
out.push_str(&value[start..]);
|
||||
return out;
|
||||
};
|
||||
|
||||
let end = start + 1 + end_rel;
|
||||
let key = &value[start + 1..end];
|
||||
if key.is_empty() {
|
||||
out.push('%');
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(v) = std::env::var(key) {
|
||||
out.push_str(&v);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push_str(&value[start..=end]);
|
||||
index = end + 1;
|
||||
}
|
||||
|
||||
out.push_str(&value[index..]);
|
||||
out
|
||||
}
|
||||
|
||||
fn extract_exe(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(rest) = value.strip_prefix('"') {
|
||||
if let Some(end) = rest.find('"') {
|
||||
let inner = rest[..end].trim();
|
||||
if inner.to_ascii_lowercase().contains(".exe") {
|
||||
return Some(inner.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lower = value.to_ascii_lowercase();
|
||||
let end = lower.find(".exe")?;
|
||||
Some(value[..end + 4].trim().trim_matches('"').to_string())
|
||||
}
|
||||
|
||||
fn candidates(app_name: &str) -> Vec<String> {
|
||||
let app_name = app_name.trim().trim_matches('"');
|
||||
if app_name.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut out = Vec::<String>::new();
|
||||
let mut push = |value: String| {
|
||||
let value = value.trim().trim_matches('"').to_string();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
if out.iter().any(|v| v.eq_ignore_ascii_case(&value)) {
|
||||
return;
|
||||
}
|
||||
out.push(value);
|
||||
};
|
||||
|
||||
push(app_name.to_string());
|
||||
|
||||
let lower = app_name.to_ascii_lowercase();
|
||||
if !lower.ends_with(".exe") {
|
||||
push(format!("{app_name}.exe"));
|
||||
}
|
||||
|
||||
let snake = {
|
||||
let mut s = String::new();
|
||||
let mut underscore = false;
|
||||
for c in lower.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
s.push(c);
|
||||
underscore = false;
|
||||
continue;
|
||||
}
|
||||
if underscore {
|
||||
continue;
|
||||
}
|
||||
s.push('_');
|
||||
underscore = true;
|
||||
}
|
||||
s.trim_matches('_').to_string()
|
||||
};
|
||||
|
||||
if !snake.is_empty() {
|
||||
push(snake.clone());
|
||||
if !snake.ends_with(".exe") {
|
||||
push(format!("{snake}.exe"));
|
||||
}
|
||||
}
|
||||
|
||||
let alnum = lower
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect::<String>();
|
||||
|
||||
if !alnum.is_empty() {
|
||||
push(alnum.clone());
|
||||
push(format!("{alnum}.exe"));
|
||||
}
|
||||
|
||||
match lower.as_str() {
|
||||
"sublime text" | "sublime-text" | "sublime_text" | "sublime text.exe" => {
|
||||
push("subl".to_string());
|
||||
push("subl.exe".to_string());
|
||||
push("sublime_text".to_string());
|
||||
push("sublime_text.exe".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn reg_app_path(exe: &str) -> Option<String> {
|
||||
let exe = exe.trim().trim_matches('"');
|
||||
if exe.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let query = |root: *mut c_void, subkey: &str| -> Option<String> {
|
||||
let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ;
|
||||
let mut kind: u32 = 0;
|
||||
let mut size = 0u32;
|
||||
|
||||
let mut key = subkey.encode_utf16().collect::<Vec<_>>();
|
||||
key.push(0);
|
||||
|
||||
let status = unsafe {
|
||||
RegGetValueW(
|
||||
root,
|
||||
key.as_ptr(),
|
||||
std::ptr::null(),
|
||||
flags,
|
||||
&mut kind,
|
||||
std::ptr::null_mut(),
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
|
||||
if status != ERROR_SUCCESS || size == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if kind != REG_SZ && kind != REG_EXPAND_SZ {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut data = vec![0u8; size as usize];
|
||||
let status = unsafe {
|
||||
RegGetValueW(
|
||||
root,
|
||||
key.as_ptr(),
|
||||
std::ptr::null(),
|
||||
flags,
|
||||
&mut kind,
|
||||
data.as_mut_ptr() as *mut c_void,
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
|
||||
if status != ERROR_SUCCESS || size < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let words = unsafe {
|
||||
std::slice::from_raw_parts(data.as_ptr().cast::<u16>(), (size as usize) / 2)
|
||||
};
|
||||
let len = words.iter().position(|v| *v == 0).unwrap_or(words.len());
|
||||
let value = String::from_utf16_lossy(&words[..len]).trim().to_string();
|
||||
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(value)
|
||||
};
|
||||
|
||||
let keys = [
|
||||
(
|
||||
HKEY_CURRENT_USER,
|
||||
format!(r"Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"),
|
||||
),
|
||||
(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
format!(r"Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"),
|
||||
),
|
||||
(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
format!(r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\{exe}"),
|
||||
),
|
||||
];
|
||||
|
||||
for (root, key) in keys {
|
||||
let Some(value) = query(root, &key) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(exe) = extract_exe(&value) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let exe = expand_env(&exe);
|
||||
let path = Path::new(exe.trim().trim_matches('"'));
|
||||
if path.exists() {
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
let app_name = app_name.trim().trim_matches('"');
|
||||
if app_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let direct = Path::new(app_name);
|
||||
if direct.is_absolute() && direct.exists() {
|
||||
return Some(direct.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
let key = app_name
|
||||
.chars()
|
||||
.filter(|v| v.is_ascii_alphanumeric())
|
||||
.flat_map(|v| v.to_lowercase())
|
||||
.collect::<String>();
|
||||
|
||||
let has_ext = |path: &Path, ext: &str| {
|
||||
path.extension()
|
||||
.and_then(|v| v.to_str())
|
||||
.map(|v| v.eq_ignore_ascii_case(ext))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
let resolve_cmd = |path: &Path| -> Option<String> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let content = String::from_utf8_lossy(&bytes);
|
||||
|
||||
for token in content.split('"') {
|
||||
let Some(exe) = extract_exe(token) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let lower = exe.to_ascii_lowercase();
|
||||
if let Some(index) = lower.find("%~dp0") {
|
||||
let base = path.parent()?;
|
||||
let suffix = &exe[index + 5..];
|
||||
let mut resolved = PathBuf::from(base);
|
||||
|
||||
for part in suffix.replace('/', "\\").split('\\') {
|
||||
if part.is_empty() || part == "." {
|
||||
continue;
|
||||
}
|
||||
if part == ".." {
|
||||
let _ = resolved.pop();
|
||||
continue;
|
||||
}
|
||||
resolved.push(part);
|
||||
}
|
||||
|
||||
if resolved.exists() {
|
||||
return Some(resolved.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved = PathBuf::from(expand_env(&exe));
|
||||
if resolved.exists() {
|
||||
return Some(resolved.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
};
|
||||
|
||||
let resolve_where = |query: &str| -> Option<String> {
|
||||
let output = Command::new("where")
|
||||
.creation_flags(0x08000000)
|
||||
.arg(query)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let paths = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if paths.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
for path in &paths {
|
||||
if has_ext(path, "cmd") || has_ext(path, "bat") {
|
||||
if let Some(resolved) = resolve_cmd(path) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
if path.extension().is_none() {
|
||||
let cmd = path.with_extension("cmd");
|
||||
if cmd.exists() {
|
||||
if let Some(resolved) = resolve_cmd(&cmd) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
let bat = path.with_extension("bat");
|
||||
if bat.exists() {
|
||||
if let Some(resolved) = resolve_cmd(&bat) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !key.is_empty() {
|
||||
for path in &paths {
|
||||
let dirs = [
|
||||
path.parent(),
|
||||
path.parent().and_then(|dir| dir.parent()),
|
||||
path.parent()
|
||||
.and_then(|dir| dir.parent())
|
||||
.and_then(|dir| dir.parent()),
|
||||
];
|
||||
|
||||
for dir in dirs.into_iter().flatten() {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let candidate = entry.path();
|
||||
if !has_ext(&candidate, "exe") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let name = stem
|
||||
.chars()
|
||||
.filter(|v| v.is_ascii_alphanumeric())
|
||||
.flat_map(|v| v.to_lowercase())
|
||||
.collect::<String>();
|
||||
|
||||
if name.contains(&key) || key.contains(&name) {
|
||||
return Some(candidate.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths.first().map(|path| path.to_string_lossy().to_string())
|
||||
};
|
||||
|
||||
let list = candidates(app_name);
|
||||
for query in &list {
|
||||
if let Some(path) = resolve_where(query) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut exes = Vec::<String>::new();
|
||||
for query in &list {
|
||||
let query = query.trim().trim_matches('"');
|
||||
if query.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = Path::new(query)
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or(query);
|
||||
|
||||
let exe = if name.to_ascii_lowercase().ends_with(".exe") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}.exe")
|
||||
};
|
||||
|
||||
if exes.iter().any(|v| v.eq_ignore_ascii_case(&exe)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
exes.push(exe);
|
||||
}
|
||||
|
||||
for exe in exes {
|
||||
if let Some(path) = reg_app_path(&exe) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.10"
|
||||
version = "1.2.13"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.13/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.13/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.13/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.13/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.13/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
136
packages/opencode/BUN_SHELL_MIGRATION_PLAN.md
Normal file
136
packages/opencode/BUN_SHELL_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Bun shell migration plan
|
||||
|
||||
Practical phased replacement of Bun `$` calls.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace runtime Bun shell template-tag usage in `packages/opencode/src` with a unified `Process` API in `util/process.ts`.
|
||||
|
||||
Keep behavior stable while improving safety, testability, and observability.
|
||||
|
||||
Current baseline from audit:
|
||||
|
||||
- 143 runtime command invocations across 17 files
|
||||
- 84 are git commands
|
||||
- Largest hotspots:
|
||||
- `src/cli/cmd/github.ts` (33)
|
||||
- `src/worktree/index.ts` (22)
|
||||
- `src/lsp/server.ts` (21)
|
||||
- `src/installation/index.ts` (20)
|
||||
- `src/snapshot/index.ts` (18)
|
||||
|
||||
## Decisions
|
||||
|
||||
- Extend `src/util/process.ts` (do not create a separate exec module).
|
||||
- Proceed with phased migration for both git and non-git paths.
|
||||
- Keep plugin `$` compatibility in 1.x and remove in 2.0.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not remove plugin `$` compatibility in this effort.
|
||||
- Do not redesign command semantics beyond what is needed to preserve behavior.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Keep migration phased, not big-bang.
|
||||
- Minimize behavioral drift.
|
||||
- Keep these explicit shell-only exceptions:
|
||||
- `src/session/prompt.ts` raw command execution
|
||||
- worktree start scripts in `src/worktree/index.ts`
|
||||
|
||||
## Process API proposal (`src/util/process.ts`)
|
||||
|
||||
Add higher-level wrappers on top of current spawn support.
|
||||
|
||||
Core methods:
|
||||
|
||||
- `Process.run(cmd, opts)`
|
||||
- `Process.text(cmd, opts)`
|
||||
- `Process.lines(cmd, opts)`
|
||||
- `Process.status(cmd, opts)`
|
||||
- `Process.shell(command, opts)` for intentional shell execution
|
||||
|
||||
Git helpers:
|
||||
|
||||
- `Process.git(args, opts)`
|
||||
- `Process.gitText(args, opts)`
|
||||
|
||||
Shared options:
|
||||
|
||||
- `cwd`, `env`, `stdin`, `stdout`, `stderr`, `abort`, `timeout`, `kill`
|
||||
- `allowFailure` / non-throw mode
|
||||
- optional redaction + trace metadata
|
||||
|
||||
Standard result shape:
|
||||
|
||||
- `code`, `stdout`, `stderr`, `duration_ms`, `cmd`
|
||||
- helpers like `text()` and `arrayBuffer()` where useful
|
||||
|
||||
## Phased rollout
|
||||
|
||||
### Phase 0: Foundation
|
||||
|
||||
- Implement Process wrappers in `src/util/process.ts`.
|
||||
- Refactor `src/util/git.ts` to use Process only.
|
||||
- Add tests for exit handling, timeout, abort, and output capture.
|
||||
|
||||
### Phase 1: High-impact hotspots
|
||||
|
||||
Migrate these first:
|
||||
|
||||
- `src/cli/cmd/github.ts`
|
||||
- `src/worktree/index.ts`
|
||||
- `src/lsp/server.ts`
|
||||
- `src/installation/index.ts`
|
||||
- `src/snapshot/index.ts`
|
||||
|
||||
Within each file, migrate git paths first where applicable.
|
||||
|
||||
### Phase 2: Remaining git-heavy files
|
||||
|
||||
Migrate git-centric call sites to `Process.git*` helpers:
|
||||
|
||||
- `src/file/index.ts`
|
||||
- `src/project/vcs.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `src/storage/storage.ts`
|
||||
- `src/cli/cmd/pr.ts`
|
||||
|
||||
### Phase 3: Remaining non-git files
|
||||
|
||||
Migrate residual non-git usages:
|
||||
|
||||
- `src/cli/cmd/tui/util/clipboard.ts`
|
||||
- `src/util/archive.ts`
|
||||
- `src/file/ripgrep.ts`
|
||||
- `src/tool/bash.ts`
|
||||
- `src/cli/cmd/uninstall.ts`
|
||||
|
||||
### Phase 4: Stabilize
|
||||
|
||||
- Remove dead wrappers and one-off patterns.
|
||||
- Keep plugin `$` compatibility isolated and documented as temporary.
|
||||
- Create linked 2.0 task for plugin `$` removal.
|
||||
|
||||
## Validation strategy
|
||||
|
||||
- Unit tests for new `Process` methods and options.
|
||||
- Integration tests on hotspot modules.
|
||||
- Smoke tests for install, snapshot, worktree, and GitHub flows.
|
||||
- Regression checks for output parsing behavior.
|
||||
|
||||
## Risk mitigation
|
||||
|
||||
- File-by-file PRs with small diffs.
|
||||
- Preserve behavior first, simplify second.
|
||||
- Keep shell-only exceptions explicit and documented.
|
||||
- Add consistent error shaping and logging at Process layer.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Runtime Bun `$` usage in `packages/opencode/src` is removed except:
|
||||
- approved shell-only exceptions
|
||||
- temporary plugin compatibility path (1.x)
|
||||
- Git paths use `Process.git*` consistently.
|
||||
- CI and targeted smoke tests pass.
|
||||
- 2.0 issue exists for plugin `$` removal.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.13",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -56,7 +56,7 @@ const migrations = await Promise.all(
|
||||
)
|
||||
console.log(`Loaded ${migrations.length} migrations`)
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const singleFlag = process.argv.includes("--single") || (!!process.env.CI && !process.argv.includes("--all"))
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
|
||||
@@ -103,11 +103,6 @@ const allTargets: {
|
||||
os: "darwin",
|
||||
arch: "x64",
|
||||
},
|
||||
{
|
||||
os: "darwin",
|
||||
arch: "x64",
|
||||
avx2: false,
|
||||
},
|
||||
{
|
||||
os: "win32",
|
||||
arch: "x64",
|
||||
|
||||
@@ -41,7 +41,7 @@ import { Config } from "@/config/config"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { z } from "zod"
|
||||
import { LoadAPIKeyError } from "ai"
|
||||
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
|
||||
type ModeOption = { id: string; name: string; description?: string }
|
||||
@@ -135,6 +135,8 @@ export namespace ACP {
|
||||
private sessionManager: ACPSessionManager
|
||||
private eventAbort = new AbortController()
|
||||
private eventStarted = false
|
||||
private bashSnapshots = new Map<string, string>()
|
||||
private toolStarts = new Set<string>()
|
||||
private permissionQueues = new Map<string, Promise<void>>()
|
||||
private permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
@@ -266,47 +268,50 @@ export namespace ACP {
|
||||
const session = this.sessionManager.tryGet(part.sessionID)
|
||||
if (!session) return
|
||||
const sessionId = session.id
|
||||
const directory = session.cwd
|
||||
|
||||
const message = await this.sdk.session
|
||||
.message(
|
||||
{
|
||||
sessionID: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
log.error("unexpected error when fetching message", { error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!message || message.info.role !== "assistant") return
|
||||
|
||||
if (part.type === "tool") {
|
||||
await this.toolStart(sessionId, part)
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: part.callID,
|
||||
title: part.tool,
|
||||
kind: toToolKind(part.tool),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool pending to ACP", { error })
|
||||
})
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
return
|
||||
|
||||
case "running":
|
||||
const output = this.bashOutput(part)
|
||||
const content: ToolCallContent[] = []
|
||||
if (output) {
|
||||
const hash = String(Bun.hash(output))
|
||||
if (part.tool === "bash") {
|
||||
if (this.bashSnapshots.get(part.callID) === hash) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error })
|
||||
})
|
||||
return
|
||||
}
|
||||
this.bashSnapshots.set(part.callID, hash)
|
||||
}
|
||||
content.push({
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: output,
|
||||
},
|
||||
})
|
||||
}
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -318,6 +323,7 @@ export namespace ACP {
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
...(content.length > 0 && { content }),
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -326,6 +332,8 @@ export namespace ACP {
|
||||
return
|
||||
|
||||
case "completed": {
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
@@ -405,6 +413,8 @@ export namespace ACP {
|
||||
return
|
||||
}
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -426,6 +436,7 @@ export namespace ACP {
|
||||
],
|
||||
rawOutput: {
|
||||
error: part.state.error,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -800,26 +811,23 @@ export namespace ACP {
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "tool") {
|
||||
await this.toolStart(sessionId, part)
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: part.callID,
|
||||
title: part.tool,
|
||||
kind: toToolKind(part.tool),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool pending to ACP", { error: err })
|
||||
})
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
break
|
||||
case "running":
|
||||
const output = this.bashOutput(part)
|
||||
const runningContent: ToolCallContent[] = []
|
||||
if (output) {
|
||||
runningContent.push({
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: output,
|
||||
},
|
||||
})
|
||||
}
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -831,6 +839,7 @@ export namespace ACP {
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
...(runningContent.length > 0 && { content: runningContent }),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -838,6 +847,8 @@ export namespace ACP {
|
||||
})
|
||||
break
|
||||
case "completed":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
@@ -916,6 +927,8 @@ export namespace ACP {
|
||||
})
|
||||
break
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -937,6 +950,7 @@ export namespace ACP {
|
||||
],
|
||||
rawOutput: {
|
||||
error: part.state.error,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1063,6 +1077,35 @@ export namespace ACP {
|
||||
}
|
||||
}
|
||||
|
||||
private bashOutput(part: ToolPart) {
|
||||
if (part.tool !== "bash") return
|
||||
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
||||
const output = part.state.metadata["output"]
|
||||
if (typeof output !== "string") return
|
||||
return output
|
||||
}
|
||||
|
||||
private async toolStart(sessionId: string, part: ToolPart) {
|
||||
if (this.toolStarts.has(part.callID)) return
|
||||
this.toolStarts.add(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: part.callID,
|
||||
title: part.tool,
|
||||
kind: toToolKind(part.tool),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool pending to ACP", { error })
|
||||
})
|
||||
}
|
||||
|
||||
private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
|
||||
const agents = await this.config.sdk.app
|
||||
.agents(
|
||||
|
||||
@@ -4,20 +4,21 @@ import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { readableStreamToText } from "bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
|
||||
export async function run(cmd: string[], options?: Process.Options) {
|
||||
log.info("running", {
|
||||
cmd: [which(), ...cmd],
|
||||
...options,
|
||||
})
|
||||
const result = Bun.spawn([which(), ...cmd], {
|
||||
const result = Process.spawn([which(), ...cmd], {
|
||||
...options,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -28,23 +29,15 @@ export namespace BunProc {
|
||||
},
|
||||
})
|
||||
const code = await result.exited
|
||||
const stdout = result.stdout
|
||||
? typeof result.stdout === "number"
|
||||
? result.stdout
|
||||
: await readableStreamToText(result.stdout)
|
||||
: undefined
|
||||
const stderr = result.stderr
|
||||
? typeof result.stderr === "number"
|
||||
? result.stderr
|
||||
: await readableStreamToText(result.stderr)
|
||||
: undefined
|
||||
const stdout = result.stdout ? await text(result.stdout) : undefined
|
||||
const stderr = result.stderr ? await text(result.stderr) : undefined
|
||||
log.info("done", {
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
if (code !== 0) {
|
||||
throw new Error(`Command failed with exit code ${result.exitCode}`)
|
||||
throw new Error(`Command failed with exit code ${code}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -93,7 +86,7 @@ export namespace BunProc {
|
||||
"--force",
|
||||
"--exact",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() ? ["--no-cache"] : []),
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
"--cwd",
|
||||
Global.Path.cache,
|
||||
pkg + "@" + version,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readableStreamToText, semver } from "bun"
|
||||
import { semver } from "bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace PackageRegistry {
|
||||
const log = Log.create({ service: "bun" })
|
||||
@@ -9,7 +11,7 @@ export namespace PackageRegistry {
|
||||
}
|
||||
|
||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||
const result = Bun.spawn([which(), "info", pkg, field], {
|
||||
const result = Process.spawn([which(), "info", pkg, field], {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -20,8 +22,8 @@ export namespace PackageRegistry {
|
||||
})
|
||||
|
||||
const code = await result.exited
|
||||
const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
|
||||
const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
|
||||
const stdout = result.stdout ? await text(result.stdout) : ""
|
||||
const stderr = result.stderr ? await text(result.stderr) : ""
|
||||
|
||||
if (code !== 0) {
|
||||
log.warn("bun info failed", { pkg, field, code, stderr })
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Global } from "../../global"
|
||||
import { Plugin } from "../../plugin"
|
||||
import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
@@ -263,8 +265,7 @@ export const AuthLoginCommand = cmd({
|
||||
if (args.url) {
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Bun.spawn({
|
||||
cmd: wellknown.auth.command,
|
||||
const proc = Process.spawn(wellknown.auth.command, {
|
||||
stdout: "pipe",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
@@ -273,7 +274,12 @@ export const AuthLoginCommand = cmd({
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const token = await new Response(proc.stdout).text()
|
||||
if (!proc.stdout) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const token = await text(proc.stdout)
|
||||
await Auth.set(args.url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UI } from "../ui"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { Process } from "../../util/process"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
|
||||
@@ -102,13 +103,17 @@ export const SessionListCommand = cmd({
|
||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||
|
||||
if (shouldPaginate) {
|
||||
const proc = Bun.spawn({
|
||||
cmd: pagerCmd(),
|
||||
const proc = Process.spawn(pagerCmd(), {
|
||||
stdin: "pipe",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
if (!proc.stdin) {
|
||||
console.log(output)
|
||||
return
|
||||
}
|
||||
|
||||
proc.stdin.write(output)
|
||||
proc.stdin.end()
|
||||
await proc.exited
|
||||
|
||||
@@ -16,10 +16,11 @@ import { useToast } from "../ui/toast"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
anthropic: 1,
|
||||
openai: 1,
|
||||
"github-copilot": 2,
|
||||
openai: 3,
|
||||
google: 4,
|
||||
"opencode-go": 3,
|
||||
anthropic: 4,
|
||||
google: 5,
|
||||
}
|
||||
|
||||
export function createDialogProviderOptions() {
|
||||
@@ -37,6 +38,7 @@ export function createDialogProviderOptions() {
|
||||
opencode: "(Recommended)",
|
||||
anthropic: "(Claude Max or API key)",
|
||||
openai: "(ChatGPT Plus/Pro or API key)",
|
||||
"opencode-go": "(Low cost)",
|
||||
}[provider.id],
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
|
||||
async onSelect() {
|
||||
@@ -214,16 +216,30 @@ function ApiMethod(props: ApiMethodProps) {
|
||||
title={props.title}
|
||||
placeholder="API key"
|
||||
description={
|
||||
props.providerID === "opencode" ? (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key.
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
|
||||
</text>
|
||||
</box>
|
||||
) : undefined
|
||||
{
|
||||
opencode: (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API
|
||||
key.
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
"opencode-go": (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models
|
||||
with generous usage limits.
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> and enable OpenCode Go
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
}[props.providerID] ?? undefined
|
||||
}
|
||||
onConfirm={async (value) => {
|
||||
if (!value) return
|
||||
|
||||
@@ -1762,11 +1762,6 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
return props.input.content
|
||||
})
|
||||
|
||||
const diagnostics = createMemo(() => {
|
||||
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
|
||||
return props.metadata.diagnostics?.[filePath] ?? []
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diagnostics !== undefined}>
|
||||
@@ -1780,15 +1775,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
content={code()}
|
||||
/>
|
||||
</line_number>
|
||||
<Show when={diagnostics().length}>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={props.input.filePath ?? ""} />
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
@@ -1972,12 +1959,6 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
|
||||
const diffContent = createMemo(() => props.metadata.diff)
|
||||
|
||||
const diagnostics = createMemo(() => {
|
||||
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
|
||||
const arr = props.metadata.diagnostics?.[filePath] ?? []
|
||||
return arr.filter((x) => x.severity === 1).slice(0, 3)
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diff !== undefined}>
|
||||
@@ -2003,18 +1984,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
<Show when={diagnostics().length}>
|
||||
<box>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
|
||||
{diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={props.input.filePath ?? ""} />
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
@@ -2086,6 +2056,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||
}
|
||||
>
|
||||
<Diff diff={file.diff} filePath={file.filePath} />
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
|
||||
</Show>
|
||||
</BlockTool>
|
||||
)}
|
||||
@@ -2163,6 +2134,29 @@ function Skill(props: ToolProps<typeof SkillTool>) {
|
||||
)
|
||||
}
|
||||
|
||||
function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]>; filePath: string }) {
|
||||
const { theme } = useTheme()
|
||||
const errors = createMemo(() => {
|
||||
const normalized = Filesystem.normalizePath(props.filePath)
|
||||
const arr = props.diagnostics?.[normalized] ?? []
|
||||
return arr.filter((x) => x.severity === 1).slice(0, 3)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={errors().length}>
|
||||
<box>
|
||||
<For each={errors()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
if (path.isAbsolute(input)) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { lazy } from "../../../../util/lazy.js"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../../../../util/filesystem"
|
||||
import { Process } from "../../../../util/process"
|
||||
|
||||
/**
|
||||
* Writes text to clipboard via OSC 52 escape sequence.
|
||||
@@ -87,7 +88,8 @@ export namespace Clipboard {
|
||||
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
|
||||
console.log("clipboard: using wl-copy")
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
@@ -96,11 +98,12 @@ export namespace Clipboard {
|
||||
if (Bun.which("xclip")) {
|
||||
console.log("clipboard: using xclip")
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
|
||||
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
@@ -109,11 +112,12 @@ export namespace Clipboard {
|
||||
if (Bun.which("xsel")) {
|
||||
console.log("clipboard: using xsel")
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
|
||||
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
@@ -125,7 +129,7 @@ export namespace Clipboard {
|
||||
console.log("clipboard: using powershell")
|
||||
return async (text: string) => {
|
||||
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
||||
const proc = Bun.spawn(
|
||||
const proc = Process.spawn(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
@@ -140,6 +144,7 @@ export namespace Clipboard {
|
||||
},
|
||||
)
|
||||
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CliRenderer } from "@opentui/core"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
export namespace Editor {
|
||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||
@@ -17,8 +18,7 @@ export namespace Editor {
|
||||
opts.renderer.suspend()
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
const parts = editor.split(" ")
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...parts, filepath],
|
||||
const proc = Process.spawn([...parts, filepath], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
|
||||
59
packages/opencode/src/cli/cmd/workspace-serve.ts
Normal file
59
packages/opencode/src/cli/cmd/workspace-serve.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Installation } from "../../installation"
|
||||
|
||||
export const WorkspaceServeCommand = cmd({
|
||||
command: "workspace-serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "starts a remote workspace websocket server",
|
||||
handler: async (args) => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Bun.serve<{ id: string }>({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === "/ws") {
|
||||
const id = Bun.randomUUIDv7()
|
||||
if (server.upgrade(req, { data: { id } })) return
|
||||
return new Response("Upgrade failed", { status: 400 })
|
||||
}
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response("ok", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
service: "workspace-server",
|
||||
ws: `ws://${server.hostname}:${server.port}/ws`,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
ws.send(JSON.stringify({ type: "ready", id: ws.data.id }))
|
||||
},
|
||||
message(ws, msg) {
|
||||
const text = typeof msg === "string" ? msg : msg.toString()
|
||||
ws.send(JSON.stringify({ type: "message", id: ws.data.id, text }))
|
||||
},
|
||||
close() {},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`workspace websocket server listening on ws://${server.hostname}:${server.port}/ws`)
|
||||
await new Promise(() => {})
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -276,7 +277,6 @@ export namespace Config {
|
||||
"@opencode-ai/plugin": targetVersion,
|
||||
}
|
||||
await Filesystem.writeJson(pkg, json)
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const hasGitIgnore = await Filesystem.exists(gitignore)
|
||||
@@ -289,7 +289,7 @@ export namespace Config {
|
||||
[
|
||||
"install",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() ? ["--no-cache"] : []),
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
],
|
||||
{ cwd: dir },
|
||||
).catch((err) => {
|
||||
@@ -342,10 +342,11 @@ export namespace Config {
|
||||
}
|
||||
|
||||
function rel(item: string, patterns: string[]) {
|
||||
const normalizedItem = item.replaceAll("\\", "/")
|
||||
for (const pattern of patterns) {
|
||||
const index = item.indexOf(pattern)
|
||||
const index = normalizedItem.indexOf(pattern)
|
||||
if (index === -1) continue
|
||||
return item.slice(index + pattern.length)
|
||||
return normalizedItem.slice(index + pattern.length)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,7 +1333,16 @@ export namespace Config {
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||
} catch (err) {}
|
||||
} catch (e) {
|
||||
try {
|
||||
// import.meta.resolve sometimes fails with newly created node_modules
|
||||
const require = createRequire(options.path)
|
||||
const resolvedPath = require.resolve(plugin)
|
||||
data.plugin[i] = pathToFileURL(resolvedPath).href
|
||||
} catch {
|
||||
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user