Compare commits

...

93 Commits

Author SHA1 Message Date
opencode-agent[bot]
5cb3545cdf Apply PR #14872: app: allow providing username and password when connecting to remote server 2026-02-24 20:16:38 +00:00
opencode-agent[bot]
aba7e3f03b Apply PR #14677: feat: add experimental hashline edit mode with dual-schema support 2026-02-24 20:16:37 +00:00
opencode-agent[bot]
6dda5d3af4 Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-02-24 20:16:36 +00:00
opencode-agent[bot]
b56783b6a0 Apply PR #14448: refactor: migrate Bun.spawn to Process utility with timeout and cleanup 2026-02-24 20:16:36 +00:00
opencode-agent[bot]
e56336d7ff Apply PR #14417: feat(core): add message delete endpoint 2026-02-24 20:16:35 +00:00
opencode-agent[bot]
5cdbd449a5 Apply PR #13968: split tui/server config 2026-02-24 20:16:35 +00:00
opencode-agent[bot]
1458272eb4 Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-02-24 20:16:35 +00:00
opencode-agent[bot]
5427fd6ac8 Apply PR #12022: feat: update tui model dialog to utilize model family to reduce noise in list 2026-02-24 20:16:34 +00:00
opencode-agent[bot]
3d98eb6fa0 Apply PR #11811: feat: make plan mode the default 2026-02-24 20:16:34 +00:00
Frank
2a87860c06 zen: gpt 5.3 codex 2026-02-24 14:49:07 -05:00
Adam
10594461bf Merge branch 'dev' into config-split-only 2026-02-24 12:24:27 -06:00
adamelmore
68cf011fd3 fix(app): ignore stale part deltas 2026-02-24 11:48:29 -06:00
Frank
f8cfb697bd zen: restrict alpha models to admin workspaces 2026-02-24 09:56:11 -05:00
Filip
c6d8e7624d fix(app): on cancel comment unhighlight lines (#14103) 2026-02-24 22:55:17 +08:00
opencode-agent[bot]
0d0d0578eb chore: generate 2026-02-24 14:49:52 +00:00
OpeOginni
cc02476ea5 refactor: replace error handling with serverErrorMessage utility and checks for if error is ConfigInvalidError (#14685) 2026-02-24 14:48:59 +00:00
Frank
5190589632 zen: remove alpha models from models endpoint 2026-02-24 09:43:18 -05:00
adamelmore
c92913e962 chore: cleanup 2026-02-24 08:21:05 -06:00
Luke Parker
082f0cc127 fix(app): preserve native path separators in file path helpers (#14912) 2026-02-25 00:03:15 +10:00
Noam Bressler
2cee947671 fix: ACP both live and load share synthetic pending status preceeding… (#14916) 2026-02-24 23:54:10 +10:00
adamelmore
e27d3d5d40 fix(app): remove filetree tooltips 2026-02-24 07:32:12 -06:00
Luke Parker
32417774c4 fix(test): replace structuredClone with spread for process.env (#14908) 2026-02-24 23:16:24 +10:00
Luke Parker
36197f5ff8 fix(win32): add 50ms tolerance for NTFS mtime fuzziness in FileTime assert (#14907) 2026-02-24 23:10:10 +10:00
Luke Parker
3d379c20c4 fix(test): replace Unix-only assumptions with cross-platform alternatives (#14906) 2026-02-24 23:03:18 +10:00
Luke Parker
06f25c78f6 fix(test): use path.sep in discovery test for cross-platform path matching (#14905) 2026-02-24 22:51:56 +10:00
Luke Parker
1a0639e5b8 fix(win32): normalize backslash paths in config rel() and file ignore (#14903) 2026-02-24 22:42:48 +10:00
Luke Parker
1af3e9e557 fix(win32): fix plugin resolution with createRequire fallback (#14898) 2026-02-24 22:20:57 +10:00
Luke Parker
a292eddeb5 fix(test): harden preload cleanup against Windows EBUSY (#14895) 2026-02-24 21:59:14 +10:00
Luke Parker
79254c1020 fix(test): normalize git excludesFile path for Windows (#14893) 2026-02-24 21:40:38 +10:00
opencode-agent[bot]
ef7f222d80 chore: generate 2026-02-24 11:15:39 +00:00
Noam Bressler
888b123387 feat: ACP - stream bash output and synthetic pending events (#14079)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-24 21:14:47 +10:00
Luke Parker
13cabae29f fix(win32): add git flags for snapshot operations and fix tests for cross-platform (#14890) 2026-02-24 21:14:16 +10:00
Luke Parker
659068942e fix(win32): handle CRLF line endings in markdown frontmatter parsing (#14886) 2026-02-24 20:33:22 +10:00
Luke Parker
3201a7d34b fix(win32): add bun prefix to console app build scripts (#14884) 2026-02-24 20:25:15 +10:00
Luke Parker
de796d9a00 fix(test): use path.join for cross-platform glob test assertions (#14837) 2026-02-24 20:07:56 +10:00
Luke Parker
a592bd9684 fix: update createOpenReviewFile test to match new call order (#14881) 2026-02-24 19:56:41 +10:00
opencode-agent[bot]
744059a00f chore: generate 2026-02-24 09:47:20 +00:00
Frank
fb6d201ee0 wip: zen lite 2026-02-24 04:45:41 -05:00
Frank
cda2af2589 wip: zen lite 2026-02-24 04:45:41 -05:00
Brendan Allan
eda71373b0 app: wait for loadFile before opening file tab 2026-02-24 16:47:55 +08:00
Brendan Allan
68afd7cb8d don't hardcode item height 2026-02-24 16:04:01 +08:00
Brendan Allan
337d63027c add username and password to stored http server info 2026-02-24 15:47:09 +08:00
Luke Parker
cf5cfb48cd upgrade to bun 1.3.10 canary and force baseline builds always (#14843) 2026-02-24 16:06:45 +10:00
Luke Parker
ae190038f8 ci: use bun baseline build to avoid segfaults (#14839) 2026-02-24 10:15:19 +10:00
Luke Parker
0269f39a17 ci: add Windows to unit test matrix (#14836) 2026-02-24 09:33:33 +10:00
Luke Parker
0a91196919 fix(win32): e2e sometimes fails because windows is weird and sometimes ipv6 (#14833) 2026-02-24 09:27:00 +10:00
Frank
284251ad66 zen: display BYOK cost 2026-02-23 18:18:47 -05:00
Luke Parker
34495a70d5 fix(win32): scripts/turbo commands would not run (#14829) 2026-02-24 09:15:25 +10:00
Luke Parker
ad5f0816a3 fix(cicd): flakey typecheck (#14828) 2026-02-24 09:13:31 +10:00
Ryan Vogel
24c63914bf fix: update workflows for better automation (#14809) 2026-02-23 16:51:29 -05:00
Shoubhit Dash
52b42258fa Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-23 15:16:20 +05:30
Shoubhit Dash
3026a005b6 test: reorder hashline config test for beta merge 2026-02-23 10:05:12 +05:30
Shoubhit Dash
a6f802d7fe fix: align codex prompt with edit routing 2026-02-22 22:39:03 +05:30
Shoubhit Dash
9ef803be82 feat: enable hashline by default 2026-02-22 22:31:33 +05:30
Shoubhit Dash
ce5c827a6e chore: remove local opencode config flags 2026-02-22 19:46:59 +05:30
Shoubhit Dash
56decd79db feat: add experimental hashline edit mode 2026-02-22 19:40:34 +05:30
Sebastian Herrlinger
ecac998125 update docs 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
0037c4b45b dry 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
db0c8ea07b ensure jsonc error handling 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
d67db5bac1 project config can override keybind 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
131dea32b0 no bun file api 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
832daaedaf substitution in comments 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
ba34df54ac align test 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
fcc615fb1c follow up 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
02a66cdff2 follow up 2026-02-21 18:15:47 +01:00
Sebastian Herrlinger
2bf7fdc0f3 init 2026-02-21 18:15:47 +01:00
Dax
b9d96cef8c Merge branch 'dev' into feature/remove-bun-spawn 2026-02-21 00:20:01 -05:00
Shantur Rathore
8a2a43f905 feat(core): add message delete endpoint 2026-02-20 23:34:44 +00:00
Dax Raad
d85102d699 revert: keep Bun.spawn in e2e-local.ts and stats.ts 2026-02-20 17:20:26 -05:00
Dax Raad
856b9e42f8 Merge remote-tracking branch 'origin/dev' into feature/remove-bun-spawn 2026-02-20 17:05:50 -05:00
Dax Raad
bd0c08c1f0 core: improve process reliability with proper cleanup and timeout handling
Replace Bun-specific stream utilities with standard Node.js APIs for better compatibility. Add automatic SIGKILL fallback when processes don't terminate within timeout period. Fix process stream reading to properly handle cancellation and avoid buffer deadlocks.
2026-02-20 16:49:37 -05:00
Adam
9d78b69cd3 wip(app): beta badge 2026-02-20 10:59:59 -06:00
Dax Raad
2c27715dc4 style: align process.ts with AGENTS.md style guide 2026-02-20 09:44:59 -05:00
Dax Raad
19178e4dba revert: keep Bun.spawn in e2e-local.ts 2026-02-20 09:42:41 -05:00
Dax Raad
deaf9c956f sync 2026-02-19 16:53:22 -05:00
Dax Raad
3e0dc15b59 fix: remove unnecessary braces from favicon glob pattern
The pattern **/{favicon}.{ico,png,svg,jpg,jpeg,webp} doesn't work with
the npm glob package. Changed to **/favicon.{ico,png,svg,jpg,jpeg,webp}
which correctly matches favicon files with any of the specified extensions.
2026-02-19 13:19:06 -05:00
Dax Raad
01b5e6487c test: rewrite glob tests based on Glob utility behavior
- Add tests for symlink following (default false, true when enabled)
- Add tests for dot option (include/exclude dotfiles)
- Add tests for scanSync
- Verify directories excluded by default (nodir: true)
- Verify directories included when include: 'all'
2026-02-19 13:13:05 -05:00
Dax Raad
9657d1bbfd fix: restore followSymlinks behavior in Glob utility
Add symlink: true to all locations that previously had followSymlinks: true:
- theme.tsx: custom themes
- config.ts: commands, agents, modes, plugins
- skill.ts: external, opencode, and custom skills
- registry.ts: custom tools

Also fix nodir to default to true (exclude directories) when include is not explicitly set to 'all'.
2026-02-19 13:11:38 -05:00
Dax Raad
bbfb7e95e0 refactor: migrate from Bun.Glob to npm glob package
Replace Bun.Glob usage with a new Glob utility wrapper around the npm 'glob' package.
This moves us off Bun-specific APIs toward standard Node.js compatible solutions.

Changes:
- Add new src/util/glob.ts utility module with scan(), scanSync(), and match()
- Default include option is 'file' (only returns files, not directories)
- Add symlink option (default: false) to control symlink following
- Migrate all 12 files using Bun.Glob to use the new Glob utility
- Add comprehensive tests for the glob utility

Breaking changes:
- Removed support for include: 'dir' option (use include: 'all' and filter manually)
- symlink now defaults to false (was true in most Bun.Glob usages)

Files migrated:
- src/util/log.ts
- src/util/filesystem.ts
- src/tool/truncation.ts
- src/session/instruction.ts
- src/storage/json-migration.ts
- src/storage/storage.ts
- src/project/project.ts
- src/cli/cmd/tui/context/theme.tsx
- src/config/config.ts
- src/tool/registry.ts
- src/skill/skill.ts
- src/file/ignore.ts
2026-02-19 12:38:24 -05:00
Dax
e31f00ad22 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-16 21:50:34 -05:00
LukeParkerDev
a90e8de050 add missing return 2026-02-11 13:24:17 +10:00
Aiden Cline
eabf770053 Merge branch 'dev' into utilize-family-in-dialog 2026-02-10 14:43:15 -06:00
Dax
86d7bdc542 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:55:01 -05:00
Dax
d3ab78bba0 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:04:40 -05:00
Dax Raad
a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad
bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad
ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad
ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad
f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad
405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad
878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
Aiden Cline
bb4d978684 feat: update tui model dialog to utilize model family to reduce noise in list 2026-02-03 15:48:40 -06:00
Dax Raad
afec40e8da feat: make plan mode the default, remove experimental flag
- Remove OPENCODE_EXPERIMENTAL_PLAN_MODE flag from flag.ts
- Update prompt.ts to always use plan mode logic
- Update registry.ts to always include plan tools in CLI
- Remove flag documentation from cli.mdx
2026-02-02 10:40:40 -05:00
155 changed files with 8634 additions and 1405 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,22 +17,30 @@ import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps {
value: string
username: string
password: string
placeholder: string
adding: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
}
interface EditRowProps {
value: string
username: string
password: string
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
}
@@ -83,12 +91,20 @@ function useServerPreview(fetcher: typeof fetch) {
return host.includes(".") || host.includes(":")
}
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth({ url: normalized }, fetcher)
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http, fetcher)
setStatus(result.healthy)
}
@@ -97,7 +113,7 @@ function useServerPreview(fetcher: typeof fetch) {
function AddRow(props: AddRowProps) {
return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
<div class="flex flex-col px-4 min-h-14 py-3 min-w-0 flex-1 gap-2">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<div
classList={{
@@ -131,34 +147,76 @@ function AddRow(props: AddRowProps) {
class="pl-7"
/>
</div>
<div class="flex gap-2 min-w-0">
<TextField
type="text"
hideLabel
placeholder="Username (optional)"
value={props.username}
disabled={props.adding}
onChange={props.onUsernameChange}
onKeyDown={props.onKeyDown}
/>
<TextField
type="password"
hideLabel
placeholder="Password (optional)"
value={props.password}
disabled={props.adding}
onChange={props.onPasswordChange}
onKeyDown={props.onKeyDown}
/>
</div>
</div>
)
}
function EditRow(props: EditRowProps) {
return (
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
/>
<div class="flex-1 min-w-0">
<div class="flex flex-col gap-2 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
<div class="flex items-center gap-3 min-w-0">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
/>
<div class="flex-1 min-w-0">
<TextField
type="text"
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.busy}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
/>
</div>
</div>
<div class="flex gap-2 min-w-0 pl-[calc(0.375rem+0.75rem)]">
<TextField
type="text"
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
placeholder="Username (optional)"
value={props.username}
disabled={props.busy}
onChange={props.onChange}
onChange={props.onUsernameChange}
onKeyDown={props.onKeyDown}
/>
<TextField
type="password"
hideLabel
placeholder="Password (optional)"
value={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
/>
</div>
</div>
@@ -179,6 +237,8 @@ export function DialogSelectServer() {
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
url: "",
username: "",
password: "",
adding: false,
error: "",
showForm: false,
@@ -187,6 +247,8 @@ export function DialogSelectServer() {
editServer: {
id: undefined as string | undefined,
value: "",
username: "",
password: "",
error: "",
busy: false,
status: undefined as boolean | undefined,
@@ -196,27 +258,29 @@ export function DialogSelectServer() {
const resetAdd = () => {
setStore("addServer", {
url: "",
username: "",
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
username: "",
password: "",
error: "",
status: undefined,
busy: false,
})
}
const replaceServer = (original: ServerConnection.Http, next: string) => {
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.HttpBase) => {
const active = server.key
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(ServerConnection.key(original))
@@ -272,7 +336,7 @@ export function DialogSelectServer() {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close()
if (persist) {
server.add(conn.http.url)
server.add(conn.http)
navigate("/")
return
}
@@ -283,7 +347,25 @@ export function DialogSelectServer() {
const handleAddChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, (next) => setStore("addServer", { status: next }))
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddUsernameChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const scrollListToBottom = () => {
@@ -297,7 +379,25 @@ export function DialogSelectServer() {
const handleEditChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { value, error: "" })
void previewStatus(value, (next) => setStore("editServer", { status: next }))
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditUsernameChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
async function handleAdd(value: string) {
@@ -310,16 +410,18 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" })
const result = await checkServerHealth({ url: normalized }, fetcher)
const http: ServerConnection.HttpBase = { url: normalized }
if (store.addServer.username) http.username = store.addServer.username
if (store.addServer.password) http.password = store.addServer.password
const result = await checkServerHealth(http, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select({ type: "http", http: { url: normalized } }, true)
await select({ type: "http", http }, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
@@ -330,22 +432,33 @@ export function DialogSelectServer() {
return
}
if (normalized === original.http.url) {
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
if (
normalized === original.http.url &&
username === original.http.username &&
password === original.http.password
) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const result = await checkServerHealth({ url: normalized }, fetcher)
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
replaceServer(original, normalized)
if (normalized === original.http.url) {
server.add(http)
} else {
replaceServer(original, http)
}
resetEdit()
}
@@ -406,18 +519,22 @@ export function DialogSelectServer() {
}
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
username={store.addServer.username}
password={store.addServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onUsernameChange={handleAddUsernameChange}
onPasswordChange={handleAddPasswordChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
@@ -435,11 +552,15 @@ export function DialogSelectServer() {
fallback={
<EditRow
value={store.editServer.value}
username={store.editServer.username}
password={store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy}
error={store.editServer.error}
status={store.editServer.status}
onChange={handleEditChange}
onUsernameChange={handleEditUsernameChange}
onPasswordChange={handleEditPasswordChange}
onKeyDown={(event) => handleEditKey(event, i)}
onBlur={() => handleEdit(i, store.editServer.value)}
/>
@@ -482,6 +603,8 @@ export function DialogSelectServer() {
setStore("editServer", {
id: i.http.url,
value: i.http.url,
username: i.http.username ?? "",
password: i.http.password ?? "",
error: "",
status: store.status[ServerConnection.key(i)]?.healthy,
})

View File

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

View File

@@ -265,6 +265,9 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">

View File

@@ -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", () => {

View File

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

View File

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

View File

@@ -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),
})
})

View File

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

View File

@@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist"
import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase
const HEALTH_POLL_INTERVAL_MS = 10_000
export function normalizeServerUrl(input: string) {
@@ -100,12 +101,14 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
list: [] as string[],
list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
const url = (x: StoredServer) => (typeof x === "string" ? x : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
const servers = [
...(props.servers ?? []),
@@ -156,13 +159,16 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active !== input) setState("active", input)
}
function add(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
function add(input: ServerConnection.HttpBase) {
const url_ = normalizeServerUrl(input.url)
if (!url_) return
return batch(() => {
const http: ServerConnection.HttpBase = { url }
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
const http: ServerConnection.HttpBase = { ...input, url: url_ }
const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) {
setStore("list", existing, http)
} else {
setStore("list", store.list.length, http)
}
const conn: ServerConnection.Http = { type: "http", http }
setState("active", ServerConnection.key(conn))
@@ -171,12 +177,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => x !== key)
const list = store.list.filter((x) => url(x) !== key)
batch(() => {
setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
}
})
}

View File

@@ -313,6 +313,8 @@ export const dict = {
"dialog.server.add.error": "Could not connect to server",
"dialog.server.add.checking": "Checking...",
"dialog.server.add.button": "Add server",
"dialog.server.add.username": "Username (optional)",
"dialog.server.add.password": "Password (optional)",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
"Connect to this server on app launch instead of starting a local server. Requires restart.",

View File

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

View File

@@ -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"])
})
})

View File

@@ -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()
})
}
}

View 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")
})
})

View 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")
}

View File

@@ -7,7 +7,7 @@
"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": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json ./.output/public/tui.json",
"start": "vite start"
},
"dependencies": {

View File

@@ -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",
@@ -344,6 +345,8 @@ export const dict = {
"workspace.usage.breakdown.output": "الخرج",
"workspace.usage.breakdown.reasoning": "المنطق",
"workspace.usage.subscription": "الاشتراك (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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": "اشتراك Lite",
"workspace.lite.subscription.message": "أنت مشترك في OpenCode Lite.",
"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.other.title": "اشتراك Lite",
"workspace.lite.other.message":
"عضو آخر في مساحة العمل هذه مشترك بالفعل في OpenCode Lite. يمكن لعضو واحد فقط لكل مساحة عمل الاشتراك.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"احصل على وصول إلى أفضل النماذج المفتوحة — Kimi K2.5، و GLM-5، و MiniMax M2.5 — مع حدود استخدام سخية مقابل $10 شهريًا.",
"workspace.lite.promo.subscribe": "الاشتراك في Lite",
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
"download.title": "OpenCode | تنزيل",
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
"download.hero.title": "تنزيل OpenCode",

View File

@@ -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",
@@ -349,6 +350,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Saída",
"workspace.usage.breakdown.reasoning": "Raciocínio",
"workspace.usage.subscription": "assinatura (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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 Lite",
"workspace.lite.subscription.message": "Você assina o OpenCode Lite.",
"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.other.title": "Assinatura Lite",
"workspace.lite.other.message":
"Outro membro neste workspace já assina o OpenCode Lite. Apenas um membro por workspace pode assinar.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Tenha acesso aos melhores modelos abertos — Kimi K2.5, GLM-5 e MiniMax M2.5 — com limites de uso generosos por $10 por mês.",
"workspace.lite.promo.subscribe": "Assinar Lite",
"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",

View File

@@ -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",
@@ -347,6 +348,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Output",
"workspace.usage.breakdown.reasoning": "Ræsonnement",
"workspace.usage.subscription": "abonnement (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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": "Lite-abonnement",
"workspace.lite.subscription.message": "Du abonnerer på OpenCode Lite.",
"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.other.title": "Lite-abonnement",
"workspace.lite.other.message":
"Et andet medlem i dette workspace abonnerer allerede på OpenCode Lite. Kun ét medlem pr. workspace kan abonnere.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Få adgang til de bedste åbne modeller — Kimi K2.5, GLM-5 og MiniMax M2.5 — med generøse forbrugsgrænser for $10 om måneden.",
"workspace.lite.promo.subscribe": "Abonner på Lite",
"workspace.lite.promo.subscribing": "Omdirigerer...",
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
"download.hero.title": "Download OpenCode",

View File

@@ -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",
@@ -349,6 +350,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Output",
"workspace.usage.breakdown.reasoning": "Reasoning",
"workspace.usage.subscription": "Abonnement (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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": "Lite-Abonnement",
"workspace.lite.subscription.message": "Du hast OpenCode Lite 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.other.title": "Lite-Abonnement",
"workspace.lite.other.message":
"Ein anderes Mitglied in diesem Workspace hat OpenCode Lite bereits abonniert. Nur ein Mitglied pro Workspace kann abonnieren.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Erhalte Zugriff auf die besten offenen Modelle — Kimi K2.5, GLM-5 und MiniMax M2.5 — mit großzügigen Nutzungslimits für $10 pro Monat.",
"workspace.lite.promo.subscribe": "Lite 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",

View File

@@ -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",
@@ -341,6 +342,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Output",
"workspace.usage.breakdown.reasoning": "Reasoning",
"workspace.usage.subscription": "subscription (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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": "Lite Subscription",
"workspace.lite.subscription.message": "You are subscribed to OpenCode Lite.",
"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.other.title": "Lite Subscription",
"workspace.lite.other.message":
"Another member in this workspace is already subscribed to OpenCode Lite. Only one member per workspace can subscribe.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Get access to the best open models — Kimi K2.5, GLM-5, and MiniMax M2.5 — with generous usage limits for $10 per month.",
"workspace.lite.promo.subscribe": "Subscribe to Lite",
"workspace.lite.promo.subscribing": "Redirecting...",
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
"download.hero.title": "Download OpenCode",

View File

@@ -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",
@@ -350,6 +351,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Salida",
"workspace.usage.breakdown.reasoning": "Razonamiento",
"workspace.usage.subscription": "suscripción (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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 Lite",
"workspace.lite.subscription.message": "Estás suscrito a OpenCode Lite.",
"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.other.title": "Suscripción Lite",
"workspace.lite.other.message":
"Otro miembro de este espacio de trabajo ya está suscrito a OpenCode Lite. Solo un miembro por espacio de trabajo puede suscribirse.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Obtén acceso a los mejores modelos abiertos — Kimi K2.5, GLM-5 y MiniMax M2.5 — con generosos límites de uso por $10 al mes.",
"workspace.lite.promo.subscribe": "Suscribirse a Lite",
"workspace.lite.promo.subscribing": "Redirigiendo...",
"download.title": "OpenCode | Descargar",
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
"download.hero.title": "Descargar OpenCode",

View File

@@ -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",
@@ -355,6 +356,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Sortie",
"workspace.usage.breakdown.reasoning": "Raisonnement",
"workspace.usage.subscription": "abonnement ({{amount}} $)",
"workspace.usage.lite": "lite ({{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,32 @@ 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 Lite",
"workspace.lite.subscription.message": "Vous êtes abonné à OpenCode Lite.",
"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.other.title": "Abonnement Lite",
"workspace.lite.other.message":
"Un autre membre de cet espace de travail est déjà abonné à OpenCode Lite. Un seul membre par espace de travail peut s'abonner.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Accédez aux meilleurs modèles ouverts — Kimi K2.5, GLM-5 et MiniMax M2.5 — avec des limites d'utilisation généreuses pour 10 $ par mois.",
"workspace.lite.promo.subscribe": "S'abonner à Lite",
"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",

View File

@@ -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",
@@ -349,6 +350,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Output",
"workspace.usage.breakdown.reasoning": "Reasoning",
"workspace.usage.subscription": "abbonamento (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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 Lite",
"workspace.lite.subscription.message": "Sei abbonato a OpenCode Lite.",
"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.other.title": "Abbonamento Lite",
"workspace.lite.other.message":
"Un altro membro in questo workspace è già abbonato a OpenCode Lite. Solo un membro per workspace può abbonarsi.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Ottieni l'accesso ai migliori modelli aperti — Kimi K2.5, GLM-5 e MiniMax M2.5 — con limiti di utilizzo generosi per $10 al mese.",
"workspace.lite.promo.subscribe": "Abbonati a Lite",
"workspace.lite.promo.subscribing": "Reindirizzamento...",
"download.title": "OpenCode | Download",
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
"download.hero.title": "Scarica OpenCode",

View File

@@ -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 プラン",
@@ -346,6 +347,8 @@ export const dict = {
"workspace.usage.breakdown.output": "出力",
"workspace.usage.breakdown.reasoning": "推論",
"workspace.usage.subscription": "サブスクリプション (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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": "Liteサブスクリプション",
"workspace.lite.subscription.message": "あなたは OpenCode Lite を購読しています。",
"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.other.title": "Liteサブスクリプション",
"workspace.lite.other.message":
"このワークスペースの別のメンバーが既に OpenCode Lite を購読しています。ワークスペースにつき1人のメンバーのみが購読できます。",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"月額$10で、十分な利用枠が設けられた最高のオープンモデル — Kimi K2.5、GLM-5、および MiniMax M2.5 — にアクセスできます。",
"workspace.lite.promo.subscribe": "Liteを購読する",
"workspace.lite.promo.subscribing": "リダイレクト中...",
"download.title": "OpenCode | ダウンロード",
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
"download.hero.title": "OpenCode をダウンロード",

View File

@@ -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 플랜",
@@ -343,6 +344,8 @@ export const dict = {
"workspace.usage.breakdown.output": "출력",
"workspace.usage.breakdown.reasoning": "추론",
"workspace.usage.subscription": "구독 (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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": "Lite 구독",
"workspace.lite.subscription.message": "현재 OpenCode Lite를 구독 중입니다.",
"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.other.title": "Lite 구독",
"workspace.lite.other.message":
"이 워크스페이스의 다른 멤버가 이미 OpenCode Lite를 구독 중입니다. 워크스페이스당 한 명의 멤버만 구독할 수 있습니다.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"월 $10의 넉넉한 사용 한도로 최고의 오픈 모델인 Kimi K2.5, GLM-5, MiniMax M2.5에 액세스하세요.",
"workspace.lite.promo.subscribe": "Lite 구독하기",
"workspace.lite.promo.subscribing": "리디렉션 중...",
"download.title": "OpenCode | 다운로드",
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
"download.hero.title": "OpenCode 다운로드",

View File

@@ -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",
@@ -347,6 +348,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Output",
"workspace.usage.breakdown.reasoning": "Resonnering",
"workspace.usage.subscription": "abonnement (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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": "Lite-abonnement",
"workspace.lite.subscription.message": "Du abonnerer på OpenCode Lite.",
"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.other.title": "Lite-abonnement",
"workspace.lite.other.message":
"Et annet medlem i dette arbeidsområdet abonnerer allerede på OpenCode Lite. Kun ett medlem per arbeidsområde kan abonnere.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Få tilgang til de beste åpne modellene — Kimi K2.5, GLM-5 og MiniMax M2.5 — med generøse bruksgrenser for $10 per måned.",
"workspace.lite.promo.subscribe": "Abonner på Lite",
"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",

View File

@@ -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",
@@ -348,6 +349,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Wyjście",
"workspace.usage.breakdown.reasoning": "Rozumowanie",
"workspace.usage.subscription": "subskrypcja (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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 Lite",
"workspace.lite.subscription.message": "Subskrybujesz OpenCode Lite.",
"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.other.title": "Subskrypcja Lite",
"workspace.lite.other.message":
"Inny członek tego obszaru roboczego już subskrybuje OpenCode Lite. Tylko jeden członek na obszar roboczy może subskrybować.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Uzyskaj dostęp do najlepszych otwartych modeli — Kimi K2.5, GLM-5 i MiniMax M2.5 — z hojnymi limitami użycia za $10 miesięcznie.",
"workspace.lite.promo.subscribe": "Subskrybuj Lite",
"workspace.lite.promo.subscribing": "Przekierowywanie...",
"download.title": "OpenCode | Pobierz",
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
"download.hero.title": "Pobierz OpenCode",

View File

@@ -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",
@@ -353,6 +354,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Выход",
"workspace.usage.breakdown.reasoning": "Reasoning (рассуждения)",
"workspace.usage.subscription": "подписка (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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": "Подписка Lite",
"workspace.lite.subscription.message": "Вы подписаны на OpenCode Lite.",
"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.other.title": "Подписка Lite",
"workspace.lite.other.message":
"Другой участник в этом рабочем пространстве уже подписан на OpenCode Lite. Только один участник в рабочем пространстве может оформить подписку.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Получите доступ к лучшим открытым моделям — Kimi K2.5, GLM-5 и MiniMax M2.5 — с щедрыми лимитами использования за $10 в месяц.",
"workspace.lite.promo.subscribe": "Подписаться на Lite",
"workspace.lite.promo.subscribing": "Перенаправление...",
"download.title": "OpenCode | Скачать",
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
"download.hero.title": "Скачать OpenCode",

View File

@@ -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",
@@ -346,6 +347,8 @@ export const dict = {
"workspace.usage.breakdown.output": "Output",
"workspace.usage.breakdown.reasoning": "Reasoning",
"workspace.usage.subscription": "สมัครสมาชิก (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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": "การสมัครสมาชิก Lite",
"workspace.lite.subscription.message": "คุณได้สมัครสมาชิก OpenCode Lite แล้ว",
"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.other.title": "การสมัครสมาชิก Lite",
"workspace.lite.other.message":
"สมาชิกคนอื่นใน Workspace นี้ได้สมัคร OpenCode Lite แล้ว สามารถสมัครได้เพียงหนึ่งคนต่อหนึ่ง Workspace เท่านั้น",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"เข้าถึงโมเดลเปิดที่ดีที่สุด — Kimi K2.5, GLM-5 และ MiniMax M2.5 — พร้อมขีดจำกัดการใช้งานมากมายในราคา $10 ต่อเดือน",
"workspace.lite.promo.subscribe": "สมัครสมาชิก Lite",
"workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...",
"download.title": "OpenCode | ดาวน์โหลด",
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
"download.hero.title": "ดาวน์โหลด OpenCode",

View File

@@ -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ı",
@@ -349,6 +350,8 @@ export const dict = {
"workspace.usage.breakdown.output": ıkış",
"workspace.usage.breakdown.reasoning": "Muhakeme",
"workspace.usage.subscription": "abonelik (${{amount}})",
"workspace.usage.lite": "lite (${{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,31 @@ 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": "Lite Aboneliği",
"workspace.lite.subscription.message": "OpenCode Lite 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.other.title": "Lite Aboneliği",
"workspace.lite.other.message":
"Bu çalışma alanındaki başka bir üye zaten OpenCode Lite abonesi. Çalışma alanı başına yalnızca bir üye abone olabilir.",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"Ayda $10 karşılığında cömert kullanım limitleriyle en iyi açık modellere — Kimi K2.5, GLM-5 ve MiniMax M2.5 — erişin.",
"workspace.lite.promo.subscribe": "Lite'a 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",

View File

@@ -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 计划",
@@ -334,6 +335,8 @@ export const dict = {
"workspace.usage.breakdown.output": "输出",
"workspace.usage.breakdown.reasoning": "推理",
"workspace.usage.subscription": "订阅 (${{amount}})",
"workspace.usage.lite": "lite (${{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,30 @@ 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": "Lite 订阅",
"workspace.lite.subscription.message": "您已订阅 OpenCode Lite。",
"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.other.title": "Lite 订阅",
"workspace.lite.other.message": "此工作区中的另一位成员已经订阅了 OpenCode Lite。每个工作区只有一名成员可以订阅。",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"每月仅需 $10 即可访问最优秀的开源模型 — Kimi K2.5, GLM-5, 和 MiniMax M2.5 — 并享受充裕的使用限额。",
"workspace.lite.promo.subscribe": "订阅 Lite",
"workspace.lite.promo.subscribing": "正在重定向...",
"download.title": "OpenCode | 下载",
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
"download.hero.title": "下载 OpenCode",

View File

@@ -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 方案",
@@ -334,6 +335,8 @@ export const dict = {
"workspace.usage.breakdown.output": "輸出",
"workspace.usage.breakdown.reasoning": "推理",
"workspace.usage.subscription": "訂閱 (${{amount}})",
"workspace.usage.lite": "lite (${{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,30 @@ 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": "Lite 訂閱",
"workspace.lite.subscription.message": "您已訂閱 OpenCode Lite。",
"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.other.title": "Lite 訂閱",
"workspace.lite.other.message": "此工作區中的另一位成員已訂閱 OpenCode Lite。每個工作區只能有一位成員訂閱。",
"workspace.lite.promo.title": "OpenCode Lite",
"workspace.lite.promo.description":
"每月只需 $10 即可使用最佳的開放模型 — Kimi K2.5、GLM-5 和 MiniMax M2.5 — 並享有慷慨的使用限制。",
"workspace.lite.promo.subscribe": "訂閱 Lite",
"workspace.lite.promo.subscribing": "重新導向中...",
"download.title": "OpenCode | 下載",
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
"download.hero.title": "下載 OpenCode",

View File

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

View File

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

View File

@@ -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,
},
}),

View File

@@ -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] },

View File

@@ -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() && sessionInfo()?.isBeta}>
<LiteSection />
</Show>
<BillingSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />

View File

@@ -0,0 +1,160 @@
.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="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-sm);
color: var(--color-text-secondary);
line-height: 1.5;
margin-top: var(--space-2);
}
[data-slot="subscribe-button"] {
align-self: flex-start;
margin-top: var(--space-4);
}
}

View File

@@ -0,0 +1,269 @@
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"
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 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="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>
<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>
</>
)
}

View File

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

View File

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

View File

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

View 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,
})
}

View File

@@ -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))),
]
})(),
]),
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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))
})
},
)
}

View File

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

View File

@@ -8,6 +8,7 @@ export namespace Identifier {
benchmark: "ben",
billing: "bil",
key: "key",
lite: "lit",
model: "mod",
payment: "pay",
provider: "prv",

View File

@@ -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")
}

View File

@@ -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",
{

View File

@@ -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,
}
},
)
}

View File

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

View File

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

View 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")
})
})

View 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)
})
})

View File

@@ -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",
},
{

View File

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

View File

@@ -2,46 +2,62 @@
import { z } from "zod"
import { Config } from "../src/config/config"
import { TuiConfig } from "../src/config/tui"
const file = process.argv[2]
console.log(file)
function generate(schema: z.ZodType) {
const result = z.toJSONSchema(schema, {
io: "input", // Generate input shape (treats optional().default() as not required)
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
override(ctx) {
const schema = ctx.jsonSchema
const result = z.toJSONSchema(Config.Info, {
io: "input", // Generate input shape (treats optional().default() as not required)
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
override(ctx) {
const schema = ctx.jsonSchema
// Preserve strictness: set additionalProperties: false for objects
if (schema && typeof schema === "object" && schema.type === "object" && schema.additionalProperties === undefined) {
schema.additionalProperties = false
}
// Add examples and default descriptions for string fields with defaults
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
// Preserve strictness: set additionalProperties: false for objects
if (
schema &&
typeof schema === "object" &&
schema.type === "object" &&
schema.additionalProperties === undefined
) {
schema.additionalProperties = false
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
},
}) as Record<string, unknown> & {
allowComments?: boolean
allowTrailingCommas?: boolean
// Add examples and default descriptions for string fields with defaults
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
},
}) as Record<string, unknown> & {
allowComments?: boolean
allowTrailingCommas?: boolean
}
// used for json lsps since config supports jsonc
result.allowComments = true
result.allowTrailingCommas = true
return result
}
// used for json lsps since config supports jsonc
result.allowComments = true
result.allowTrailingCommas = true
const configFile = process.argv[2]
const tuiFile = process.argv[3]
await Bun.write(file, JSON.stringify(result, null, 2))
console.log(configFile)
await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
if (tuiFile) {
console.log(tuiFile)
await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2))
}

View File

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

View File

@@ -63,6 +63,7 @@ export namespace Agent {
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
edit: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

View File

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

View File

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

View File

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

View File

@@ -365,6 +365,11 @@ export const RunCommand = cmd({
action: "deny",
pattern: "*",
},
{
permission: "edit",
action: "allow",
pattern: "*",
},
]
function title() {

View File

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

View File

@@ -38,6 +38,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -104,6 +106,7 @@ import type { EventSource } from "./context/sdk"
export function tui(input: {
url: string
args: Args
config: TuiConfig.Info
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
@@ -138,35 +141,37 @@ export function tui(input: {
<KVProvider>
<ToastProvider>
<RouteProvider>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
@@ -457,6 +462,7 @@ function App() {
{
title: "Toggle MCPs",
value: "mcp.list",
search: "toggle mcps",
category: "Agent",
slash: {
name: "mcps",
@@ -532,8 +538,9 @@ function App() {
category: "System",
},
{
title: "Toggle appearance",
title: mode() === "dark" ? "Light mode" : "Dark mode",
value: "theme.switch_mode",
search: "toggle appearance",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -572,6 +579,7 @@ function App() {
},
{
title: "Toggle debug panel",
search: "toggle debug",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
@@ -581,6 +589,7 @@ function App() {
},
{
title: "Toggle console",
search: "toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
@@ -621,6 +630,7 @@ function App() {
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
search: "toggle terminal title",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
@@ -636,6 +646,7 @@ function App() {
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
search: "toggle animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
@@ -645,6 +656,7 @@ function App() {
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
search: "toggle diff wrapping",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")

View File

@@ -2,6 +2,9 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -63,8 +66,13 @@ export const AttachCommand = cmd({
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const config = await Instance.provide({
directory: directory && existsSync(directory) ? directory : process.cwd(),
fn: () => TuiConfig.get(),
})
await tui({
url: args.url,
config,
args: {
continue: args.continue,
sessionID: args.session,

View File

@@ -7,6 +7,27 @@ import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import type { Provider } from "@opencode-ai/sdk/v2"
function pickLatest(models: [string, Provider["models"][string]][]) {
const picks: Record<string, [string, Provider["models"][string]]> = {}
for (const item of models) {
const model = item[0]
const info = item[1]
const key = info.family ?? model
const prev = picks[key]
if (!prev) {
picks[key] = item
continue
}
if (info.release_date !== prev[1].release_date) {
if (info.release_date > prev[1].release_date) picks[key] = item
continue
}
if (model > prev[0]) picks[key] = item
}
return Object.values(picks)
}
export function useConnected() {
const sync = useSync()
@@ -21,6 +42,7 @@ export function DialogModel(props: { providerID?: string }) {
const dialog = useDialog()
const keybind = useKeybind()
const [query, setQuery] = createSignal("")
const [all, setAll] = createSignal(false)
const connected = useConnected()
const providers = createDialogProviderOptions()
@@ -72,8 +94,8 @@ export function DialogModel(props: { providerID?: string }) {
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
flatMap((provider) => {
const items = pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
@@ -104,8 +126,9 @@ export function DialogModel(props: { providerID?: string }) {
(x) => x.footer !== "Free",
(x) => x.title,
),
),
),
)
return items
}),
)
const popularProviders = !connected()
@@ -154,6 +177,13 @@ export function DialogModel(props: { providerID?: string }) {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
{
keybind: keybind.all.model_show_all_toggle?.[0],
title: all() ? "Show latest only" : "Show all models",
onTrigger: () => {
setAll((value) => !value)
},
},
]}
onFilter={setQuery}
flat={true}

View File

@@ -77,6 +77,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
function promptModelWarning() {
toast.show({
@@ -170,6 +171,17 @@ export function Prompt(props: PromptProps) {
command.register(() => {
return [
{
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
value: "permission.auto_accept.toggle",
search: "toggle permissions",
keybind: "permission_auto_accept_toggle",
category: "Agent",
onSelect: (dialog) => {
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
dialog.clear()
},
},
{
title: "Clear prompt",
value: "prompt.clear",
@@ -996,23 +1008,30 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
</Show>
</box>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
</Show>
</box>
<Show when={autoaccept() === "edit"}>
<text>
<span style={{ fg: theme.warning }}>autoedit</span>
</text>
</Show>
</box>
</box>

View File

@@ -80,11 +80,11 @@ const TIPS = [
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
"Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
"Configure {highlight}model{/highlight} in config to set your default model",
"Override any keybind in config via the {highlight}keybinds{/highlight} section",
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
"Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
@@ -140,7 +140,7 @@ const TIPS = [
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",

View File

@@ -1,5 +1,4 @@
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
@@ -7,14 +6,15 @@ import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { useTuiConfig } from "./tui-config"
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
const sync = useSync()
const keybinds = createMemo(() => {
const config = useTuiConfig()
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
return pipe(
sync.data.config.keybinds ?? {},
(config.keybinds ?? {}) as Record<string, string>,
mapValues((value) => Keybind.parse(value)),
)
})

View File

@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { useKV } from "./kv"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
@@ -103,6 +104,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const sdk = useSDK()
const kv = useKV()
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
sdk.event.listen((e) => {
const event = e.details
@@ -127,6 +130,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "permission.asked": {
const request = event.properties
if (autoaccept() === "edit" && request.permission === "edit") {
sdk.client.permission.reply({
reply: "once",
requestID: request.id,
})
break
}
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
@@ -441,6 +451,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.status !== "loading"
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)

View File

@@ -1,7 +1,6 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
@@ -42,6 +41,7 @@ import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config"
type ThemeColors = {
primary: RGBA
@@ -280,17 +280,17 @@ function ansiToRgba(code: number): RGBA {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
const sync = useSync()
const config = useTuiConfig()
const kv = useKV()
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
createEffect(() => {
const theme = sync.data.config.theme
const theme = config.theme
if (theme) setStore("active", theme)
})

View File

@@ -0,0 +1,9 @@
import { TuiConfig } from "@/config/tui"
import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
name: "TuiConfig",
init: (props: { config: TuiConfig.Info }) => {
return props.config
},
})

View File

@@ -46,6 +46,7 @@ export function Home() {
{
title: tipsHidden() ? "Show tips" : "Hide tips",
value: "tips.toggle",
search: "toggle tips",
keybind: "tips_toggle",
category: "System",
onSelect: (dialog) => {

View File

@@ -78,6 +78,7 @@ import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
addDefaultParsers(parsers.parsers)
@@ -101,6 +102,7 @@ const context = createContext<{
showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
tui: ReturnType<typeof useTuiConfig>
}>()
function use() {
@@ -113,6 +115,7 @@ export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
const tuiConfig = useTuiConfig()
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
@@ -166,7 +169,7 @@ export function Session() {
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => {
const tui = sync.data.config.tui
const tui = tuiConfig
if (tui?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
@@ -525,6 +528,7 @@ export function Session() {
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
search: "toggle sidebar",
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -539,6 +543,7 @@ export function Session() {
{
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
search: "toggle code concealment",
keybind: "messages_toggle_conceal" as any,
category: "Session",
onSelect: (dialog) => {
@@ -549,6 +554,7 @@ export function Session() {
{
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
value: "session.toggle.timestamps",
search: "toggle timestamps",
category: "Session",
slash: {
name: "timestamps",
@@ -562,6 +568,7 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
search: "toggle thinking",
keybind: "display_thinking",
category: "Session",
slash: {
@@ -576,6 +583,7 @@ export function Session() {
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
search: "toggle tool details",
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
@@ -584,8 +592,9 @@ export function Session() {
},
},
{
title: "Toggle session scrollbar",
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
value: "session.toggle.scrollbar",
search: "toggle session scrollbar",
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -988,6 +997,7 @@ export function Session() {
showGenericToolOutput,
diffWrapMode,
sync,
tui: tuiConfig,
}}
>
<box flexDirection="row">
@@ -1962,7 +1972,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
const { theme, syntax } = useTheme()
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
// Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified"
@@ -2019,7 +2029,9 @@ function Edit(props: ToolProps<typeof EditTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
Edit{" "}
{normalizePath(props.input.filePath!)}{" "}
{input({ replaceAll: "replaceAll" in props.input ? props.input.replaceAll : undefined })}
</InlineTool>
</Match>
</Switch>
@@ -2033,7 +2045,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const files = createMemo(() => props.metadata.files ?? [])
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
return ctx.width > 120 ? "split" : "unified"
})

View File

@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
type PermissionStage = "permission" | "always" | "reject"
@@ -48,14 +49,14 @@ function EditBody(props: { request: PermissionRequest }) {
const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
const sync = useSync()
const config = useTuiConfig()
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
const view = createMemo(() => {
const diffStyle = sync.data.config.tui?.diff_style
const diffStyle = config.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})

View File

@@ -12,6 +12,8 @@ import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -135,6 +137,10 @@ export const TuiThreadCommand = cmd({
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})
const config = await Instance.provide({
directory: cwd,
fn: () => TuiConfig.get(),
})
// Check if server should be started (port or hostname explicitly set in CLI or config)
const networkOpts = await resolveNetworkOptions(args)
@@ -163,6 +169,8 @@ export const TuiThreadCommand = cmd({
const tuiPromise = tui({
url,
config,
directory: cwd,
fetch: customFetch,
events,
args: {

View File

@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
search?: string
footer?: JSX.Element | string
category?: string
disabled?: boolean
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
// users typically search by the item name, and not its category.
const result = fuzzysort
.go(needle, options, {
keys: ["title", "category"],
scoreFn: (r) => r[0].score * 2 + r[1].score,
keys: ["title", "category", "search"],
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
})
.map((x) => x.obj)

View File

@@ -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(() => {})

View File

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

View File

@@ -1,9 +1,9 @@
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"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
@@ -33,6 +33,8 @@ import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Control } from "@/control"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -41,7 +43,7 @@ export namespace Config {
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
// These settings override all user and project settings
function getManagedConfigDir(): string {
function systemManagedConfigDir(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/opencode"
@@ -52,10 +54,14 @@ export namespace Config {
}
}
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
export function managedConfigDir() {
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
}
const managedDir = managedConfigDir()
// Custom merge function that concatenates array fields instead of replacing them
function merge(target: Info, source: Info): Info {
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
@@ -90,7 +96,7 @@ export namespace Config {
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = merge(
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(remoteConfig), {
dir: path.dirname(`${key}/.well-known/opencode`),
@@ -106,21 +112,18 @@ export namespace Config {
}
// Global user config overrides remote config.
result = merge(result, await global())
result = mergeConfigConcatArrays(result, await global())
// Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) {
result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
// Project config overrides global and remote config.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) {
result = merge(result, await loadFile(resolved))
}
for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
result = mergeConfigConcatArrays(result, await loadFile(file))
}
}
@@ -128,31 +131,10 @@ export namespace Config {
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = [
Global.Path.config,
// Only scan project .opencode/ directories when project discovery is enabled
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Instance.directory,
stop: Instance.worktree,
}),
)
: []),
// Always scan ~/.opencode/ (user home directory)
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
]
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
// .opencode directory config overrides (project and global) config sources.
if (Flag.OPENCODE_CONFIG_DIR) {
directories.push(Flag.OPENCODE_CONFIG_DIR)
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
@@ -162,7 +144,7 @@ export namespace Config {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = merge(result, await loadFile(path.join(dir, file)))
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
@@ -185,7 +167,7 @@ export namespace Config {
// Inline config content overrides all non-managed config sources.
if (process.env.OPENCODE_CONFIG_CONTENT) {
result = merge(
result = mergeConfigConcatArrays(
result,
await load(process.env.OPENCODE_CONFIG_CONTENT, {
dir: Instance.directory,
@@ -199,9 +181,9 @@ export namespace Config {
// Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions
// This way it only loads config file and not skills/plugins/commands
if (existsSync(managedConfigDir)) {
if (existsSync(managedDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
result = merge(result, await loadFile(path.join(managedConfigDir, file)))
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
}
}
@@ -240,8 +222,6 @@ export namespace Config {
result.share = "auto"
}
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
// Apply flag overrides for compaction settings
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
@@ -276,7 +256,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)
@@ -306,7 +285,7 @@ export namespace Config {
}
}
async function needsInstall(dir: string) {
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
@@ -342,10 +321,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)
}
}
@@ -791,6 +771,7 @@ export namespace Config {
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
@@ -831,7 +812,12 @@ export namespace Config {
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
permission_auto_accept_toggle: z
.string()
.optional()
.default("shift+tab")
.describe("Toggle auto-accept mode for permissions"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
@@ -929,20 +915,6 @@ export namespace Config {
ref: "KeybindsConfig",
})
export const TUI = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
.object({
enabled: z.boolean().describe("Enable scroll acceleration"),
})
.optional()
.describe("Scroll acceleration settings"),
diff_style: z
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
})
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
@@ -1017,10 +989,7 @@ export namespace Config {
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
logLevel: Log.Level.optional().describe("Log level"),
tui: TUI.optional().describe("TUI specific settings"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), Command)
@@ -1186,6 +1155,16 @@ export namespace Config {
.object({
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
hashline_edit: z
.boolean()
.optional()
.describe("Enable hashline-backed edit/read tool behavior (default true, set false to disable)"),
hashline_autocorrect: z
.boolean()
.optional()
.describe(
"Enable hashline autocorrect cleanup for copied prefixes and formatting artifacts (default true)",
),
openTelemetry: z
.boolean()
.optional()
@@ -1240,86 +1219,37 @@ export namespace Config {
return result
})
export const { readFile } = ConfigPaths
async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath })
let text = await Filesystem.readText(filepath).catch((err: any) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
const text = await readFile(filepath)
if (!text) return {}
return load(text, { path: filepath })
}
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
const original = text
const configDir = "path" in options ? path.dirname(options.path) : options.dir
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
const data = await ConfigPaths.parseText(
text,
"path" in options ? options.path : { source: options.source, dir: options.dir },
)
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
})()
const fileMatches = text.match(/\{file:[^}]+\}/g)
if (fileMatches) {
const lines = text.split("\n")
for (const match of fileMatches) {
const lineIndex = lines.findIndex((line) => line.includes(match))
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
continue
}
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Bun.file(resolvedPath)
.text()
.catch((error) => {
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: source,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: source, message: errMsg }, { cause: error })
})
).trim()
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
}
}
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: source,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
const parsed = Info.safeParse(data)
const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
@@ -1332,7 +1262,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
@@ -1343,13 +1282,7 @@ export namespace Config {
issues: parsed.error.issues,
})
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const { JsonError, InvalidError } = ConfigPaths
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
@@ -1360,15 +1293,6 @@ export namespace Config {
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
export async function get() {
return state().then((x) => x.config)
}

View File

@@ -22,7 +22,7 @@ export namespace ConfigMarkdown {
if (!match) return content
const frontmatter = match[1]
const lines = frontmatter.split("\n")
const lines = frontmatter.split(/\r?\n/)
const result: string[] = []
for (const line of lines) {

View File

@@ -0,0 +1,155 @@
import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda"
import z from "zod"
import { ConfigPaths } from "./paths"
import { TuiInfo, TuiOptions } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
const log = Log.create({ service: "tui.migrate" })
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
const LegacyTheme = TuiInfo.shape.theme.optional()
const LegacyRecord = z.record(z.string(), z.unknown()).optional()
const TuiLegacy = z
.object({
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
diff_style: TuiOptions.shape.diff_style.catch(undefined),
})
.strip()
interface MigrateInput {
directories: string[]
custom?: string
managed: string
}
/**
* Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files
* into dedicated tui.json files. Migration is performed per-directory and
* skips only locations where a tui.json already exists.
*/
export async function migrateTuiConfig(input: MigrateInput) {
const opencode = await opencodeFiles(input)
for (const file of opencode) {
const source = await Filesystem.readText(file).catch((error) => {
log.warn("failed to read config for tui migration", { path: file, error })
return undefined
})
if (!source) continue
const errors: JsoncParseError[] = []
const data = parseJsonc(source, errors, { allowTrailingComma: true })
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
const extracted = {
theme: theme.success ? theme.data : undefined,
keybinds: keybinds.success ? keybinds.data : undefined,
tui: legacyTui.success ? legacyTui.data : undefined,
}
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
const target = path.join(path.dirname(file), "tui.json")
const targetExists = await Filesystem.exists(target)
if (targetExists) continue
const payload: Record<string, unknown> = {
$schema: TUI_SCHEMA_URL,
}
if (extracted.theme !== undefined) payload.theme = extracted.theme
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", { from: file, to: target, error })
return false
})
if (!wrote) continue
const stripped = await backupAndStripLegacy(file, source)
if (!stripped) {
log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
continue
}
log.info("migrated tui config", { from: file, to: target })
}
}
function normalizeTui(data: Record<string, unknown>) {
const parsed = TuiLegacy.parse(data)
if (
parsed.scroll_speed === undefined &&
parsed.diff_style === undefined &&
parsed.scroll_acceleration === undefined
) {
return
}
return parsed
}
async function backupAndStripLegacy(file: string, source: string) {
const backup = file + ".tui-migration.bak"
const hasBackup = await Filesystem.exists(backup)
const backed = hasBackup
? true
: await Bun.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
return false
})
if (!backed) return false
const text = ["theme", "keybinds", "tui"].reduce((acc, key) => {
const edits = modify(acc, [key], undefined, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
},
})
if (!edits.length) return acc
return applyEdits(acc, edits)
}, source)
return Bun.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true
})
.catch((error) => {
log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
return false
})
}
async function opencodeFiles(input: { directories: string[]; managed: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
}
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
const existing = await Promise.all(
unique(files).map(async (file) => {
const ok = await Filesystem.exists(file)
return ok ? file : undefined
}),
)
return existing.filter((file): file is string => !!file)
}

View File

@@ -0,0 +1,174 @@
import path from "path"
import os from "os"
import z from "zod"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { NamedError } from "@opencode-ai/util/error"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) {
const files: string[] = []
for (const file of [`${name}.jsonc`, `${name}.json`]) {
const found = await Filesystem.findUp(file, directory, worktree)
for (const resolved of found.toReversed()) {
files.push(resolved)
}
}
return files
}
export async function directories(directory: string, worktree: string) {
return [
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: directory,
stop: worktree,
}),
)
: []),
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
]
}
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
}
type ParseSource = string | { source: string; dir: string }
function source(input: ParseSource) {
return typeof input === "string" ? input : input.source
}
function dir(input: ParseSource) {
return typeof input === "string" ? path.dirname(input) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
const configSource = source(input)
text = await substitute(text, input, missing)
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configSource,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
return data
}
}

View File

@@ -0,0 +1,34 @@
import z from "zod"
import { Config } from "./config"
const KeybindOverride = z
.object(
Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
string,
z.ZodOptional<z.ZodString>
>,
)
.strict()
export const TuiOptions = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
.object({
enabled: z.boolean().describe("Enable scroll acceleration"),
})
.optional()
.describe("Scroll acceleration settings"),
diff_style: z
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
})
export const TuiInfo = z
.object({
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
})
.extend(TuiOptions.shape)
.strict()

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